From d37e3d582fd8193c5b645de5ead8d7a14a137e17 Mon Sep 17 00:00:00 2001 From: Sally O'Malley Date: Sun, 15 Mar 2026 13:08:37 -0400 Subject: [PATCH 001/943] Scope Control UI sessions per gateway (#47453) * Scope Control UI sessions per gateway Signed-off-by: sallyom * Add changelog for Control UI session scoping Signed-off-by: sallyom --------- Signed-off-by: sallyom --- CHANGELOG.md | 1 + ui/src/ui/app-settings.test.ts | 83 ++++++++++++++++++++++ ui/src/ui/app-settings.ts | 12 ++++ ui/src/ui/storage.node.test.ts | 122 +++++++++++++++++++++++++++++++-- ui/src/ui/storage.ts | 90 ++++++++++++++++++++---- 5 files changed, 291 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1b29a7d668..5de1f1b05f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Group mention gating: reject invalid and unsafe nested-repetition `mentionPatterns`, reuse the shared safe config-regex compiler across mention stripping and detection, and cache strip-time regex compilation so noisy groups avoid repeated recompiles. - Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong. +- Control UI: scope persisted session selection per gateway, prevent stale session bleed across tokenized gateway opens, and cap stored gateway session history. (#47453) Thanks @sallyom. - Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc. - Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. (#45254) Thanks @Coobiw. - Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob. diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts index aecc1f5bbcb..fd02f7673e9 100644 --- a/ui/src/ui/app-settings.test.ts +++ b/ui/src/ui/app-settings.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { applyResolvedTheme, applySettings, + applySettingsFromUrl, attachThemeListener, setTabFromRoute, syncThemeWithSettings, @@ -60,6 +61,8 @@ type SettingsHost = { themeMediaHandler: ((event: MediaQueryListEvent) => void) | null; logsPollInterval: number | null; debugPollInterval: number | null; + pendingGatewayUrl?: string | null; + pendingGatewayToken?: string | null; }; function createStorageMock(): Storage { @@ -118,6 +121,8 @@ const createHost = (tab: Tab): SettingsHost => ({ themeMediaHandler: null, logsPollInterval: null, debugPollInterval: null, + pendingGatewayUrl: null, + pendingGatewayToken: null, }); describe("setTabFromRoute", () => { @@ -224,3 +229,81 @@ describe("setTabFromRoute", () => { expect(root.style.colorScheme).toBe("light"); }); }); + +describe("applySettingsFromUrl", () => { + beforeEach(() => { + vi.stubGlobal("localStorage", createStorageMock()); + vi.stubGlobal("navigator", { language: "en-US" } as Navigator); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + window.history.replaceState({}, "", "/chat"); + }); + + it("resets stale persisted session selection to main when a token is supplied without a session", () => { + const host = createHost("chat"); + host.settings = { + ...host.settings, + gatewayUrl: "ws://localhost:18789", + token: "", + sessionKey: "agent:test_old:main", + lastActiveSessionKey: "agent:test_old:main", + }; + host.sessionKey = "agent:test_old:main"; + + window.history.replaceState({}, "", "/chat#token=test-token"); + + applySettingsFromUrl(host); + + expect(host.sessionKey).toBe("main"); + expect(host.settings.sessionKey).toBe("main"); + expect(host.settings.lastActiveSessionKey).toBe("main"); + }); + + it("preserves an explicit session from the URL when token and session are both supplied", () => { + const host = createHost("chat"); + host.settings = { + ...host.settings, + gatewayUrl: "ws://localhost:18789", + token: "", + sessionKey: "agent:test_old:main", + lastActiveSessionKey: "agent:test_old:main", + }; + host.sessionKey = "agent:test_old:main"; + + window.history.replaceState({}, "", "/chat?session=agent%3Atest_new%3Amain#token=test-token"); + + applySettingsFromUrl(host); + + expect(host.sessionKey).toBe("agent:test_new:main"); + expect(host.settings.sessionKey).toBe("agent:test_new:main"); + expect(host.settings.lastActiveSessionKey).toBe("agent:test_new:main"); + }); + + it("does not reset the current gateway session when a different gateway is pending confirmation", () => { + const host = createHost("chat"); + host.settings = { + ...host.settings, + gatewayUrl: "ws://gateway-a.example:18789", + token: "", + sessionKey: "agent:test_old:main", + lastActiveSessionKey: "agent:test_old:main", + }; + host.sessionKey = "agent:test_old:main"; + + window.history.replaceState( + {}, + "", + "/chat?gatewayUrl=ws%3A%2F%2Fgateway-b.example%3A18789#token=test-token", + ); + + applySettingsFromUrl(host); + + expect(host.sessionKey).toBe("agent:test_old:main"); + expect(host.settings.sessionKey).toBe("agent:test_old:main"); + expect(host.settings.lastActiveSessionKey).toBe("agent:test_old:main"); + expect(host.pendingGatewayUrl).toBe("ws://gateway-b.example:18789"); + expect(host.pendingGatewayToken).toBe("test-token"); + }); +}); diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 50575826813..23f1de68caa 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -100,6 +100,9 @@ export function applySettingsFromUrl(host: SettingsHost) { const tokenRaw = hashParams.get("token"); const passwordRaw = params.get("password") ?? hashParams.get("password"); const sessionRaw = params.get("session") ?? hashParams.get("session"); + const shouldResetSessionForToken = Boolean( + tokenRaw?.trim() && !sessionRaw?.trim() && !gatewayUrlChanged, + ); let shouldCleanUrl = false; if (params.has("token")) { @@ -118,6 +121,15 @@ export function applySettingsFromUrl(host: SettingsHost) { shouldCleanUrl = true; } + if (shouldResetSessionForToken) { + host.sessionKey = "main"; + applySettings(host, { + ...host.settings, + sessionKey: "main", + lastActiveSessionKey: "main", + }); + } + if (passwordRaw != null) { // Never hydrate password from URL params; strip only. params.delete("password"); diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index 64ce3aec95c..2222e193e96 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -126,8 +126,6 @@ describe("loadSettings default gateway URL derivation", () => { }); expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({ gatewayUrl: "wss://gateway.example:8443/openclaw", - sessionKey: "agent", - lastActiveSessionKey: "agent", theme: "claw", themeMode: "system", chatFocusMode: false, @@ -137,6 +135,12 @@ describe("loadSettings default gateway URL derivation", () => { navCollapsed: false, navWidth: 220, navGroupsCollapsed: {}, + sessionsByGateway: { + "wss://gateway.example:8443/openclaw": { + sessionKey: "agent", + lastActiveSessionKey: "agent", + }, + }, }); expect(sessionStorage.length).toBe(0); }); @@ -249,8 +253,6 @@ describe("loadSettings default gateway URL derivation", () => { expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({ gatewayUrl: "wss://gateway.example:8443/openclaw", - sessionKey: "main", - lastActiveSessionKey: "main", theme: "claw", themeMode: "system", chatFocusMode: false, @@ -260,6 +262,12 @@ describe("loadSettings default gateway URL derivation", () => { navCollapsed: false, navWidth: 220, navGroupsCollapsed: {}, + sessionsByGateway: { + "wss://gateway.example:8443/openclaw": { + sessionKey: "main", + lastActiveSessionKey: "main", + }, + }, }); expect(sessionStorage.length).toBe(1); }); @@ -337,4 +345,110 @@ describe("loadSettings default gateway URL derivation", () => { navWidth: 320, }); }); + + it("scopes persisted session selection per gateway", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + + const { loadSettings, saveSettings } = await import("./storage.ts"); + + saveSettings({ + gatewayUrl: "wss://gateway-a.example:8443/openclaw", + token: "", + sessionKey: "agent:test_old:main", + lastActiveSessionKey: "agent:test_old:main", + theme: "claw", + themeMode: "system", + chatFocusMode: false, + chatShowThinking: true, + chatShowToolCalls: true, + splitRatio: 0.6, + navCollapsed: false, + navWidth: 220, + navGroupsCollapsed: {}, + }); + + saveSettings({ + gatewayUrl: "wss://gateway-b.example:8443/openclaw", + token: "", + sessionKey: "agent:test_new:main", + lastActiveSessionKey: "agent:test_new:main", + theme: "claw", + themeMode: "system", + chatFocusMode: false, + chatShowThinking: true, + chatShowToolCalls: true, + splitRatio: 0.6, + navCollapsed: false, + navWidth: 220, + navGroupsCollapsed: {}, + }); + + localStorage.setItem( + "openclaw.control.settings.v1", + JSON.stringify({ + ...JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}"), + gatewayUrl: "wss://gateway-a.example:8443/openclaw", + }), + ); + + expect(loadSettings()).toMatchObject({ + gatewayUrl: "wss://gateway-a.example:8443/openclaw", + sessionKey: "agent:test_old:main", + lastActiveSessionKey: "agent:test_old:main", + }); + + localStorage.setItem( + "openclaw.control.settings.v1", + JSON.stringify({ + ...JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}"), + gatewayUrl: "wss://gateway-b.example:8443/openclaw", + }), + ); + + expect(loadSettings()).toMatchObject({ + gatewayUrl: "wss://gateway-b.example:8443/openclaw", + sessionKey: "agent:test_new:main", + lastActiveSessionKey: "agent:test_new:main", + }); + }); + + it("caps persisted session scopes to the most recent gateways", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + + const { saveSettings } = await import("./storage.ts"); + + for (let i = 0; i < 12; i += 1) { + saveSettings({ + gatewayUrl: `wss://gateway-${i}.example:8443/openclaw`, + token: "", + sessionKey: `agent:test_${i}:main`, + lastActiveSessionKey: `agent:test_${i}:main`, + theme: "claw", + themeMode: "system", + chatFocusMode: false, + chatShowThinking: true, + chatShowToolCalls: true, + splitRatio: 0.6, + navCollapsed: false, + navWidth: 220, + navGroupsCollapsed: {}, + }); + } + + const persisted = JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}"); + const scopes = Object.keys(persisted.sessionsByGateway ?? {}); + + expect(scopes).toHaveLength(10); + expect(scopes).not.toContain("wss://gateway-0.example:8443/openclaw"); + expect(scopes).not.toContain("wss://gateway-1.example:8443/openclaw"); + expect(scopes).toContain("wss://gateway-11.example:8443/openclaw"); + }); }); diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 02e826b3a1d..450c5124592 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -1,8 +1,19 @@ const KEY = "openclaw.control.settings.v1"; const LEGACY_TOKEN_SESSION_KEY = "openclaw.control.token.v1"; const TOKEN_SESSION_KEY_PREFIX = "openclaw.control.token.v1:"; +const MAX_SCOPED_SESSION_ENTRIES = 10; -type PersistedUiSettings = Omit & { token?: never }; +type ScopedSessionSelection = { + sessionKey: string; + lastActiveSessionKey: string; +}; + +type PersistedUiSettings = Omit & { + token?: never; + sessionKey?: string; + lastActiveSessionKey?: string; + sessionsByGateway?: Record; +}; import { isSupportedLocale } from "../i18n/index.ts"; import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts"; @@ -87,6 +98,41 @@ function tokenSessionKeyForGateway(gatewayUrl: string): string { return `${TOKEN_SESSION_KEY_PREFIX}${normalizeGatewayTokenScope(gatewayUrl)}`; } +function resolveScopedSessionSelection( + gatewayUrl: string, + parsed: PersistedUiSettings, + defaults: UiSettings, +): ScopedSessionSelection { + const scope = normalizeGatewayTokenScope(gatewayUrl); + const scoped = parsed.sessionsByGateway?.[scope]; + if ( + scoped && + typeof scoped.sessionKey === "string" && + scoped.sessionKey.trim() && + typeof scoped.lastActiveSessionKey === "string" && + scoped.lastActiveSessionKey.trim() + ) { + return { + sessionKey: scoped.sessionKey.trim(), + lastActiveSessionKey: scoped.lastActiveSessionKey.trim(), + }; + } + + const legacySessionKey = + typeof parsed.sessionKey === "string" && parsed.sessionKey.trim() + ? parsed.sessionKey.trim() + : defaults.sessionKey; + const legacyLastActiveSessionKey = + typeof parsed.lastActiveSessionKey === "string" && parsed.lastActiveSessionKey.trim() + ? parsed.lastActiveSessionKey.trim() + : legacySessionKey || defaults.lastActiveSessionKey; + + return { + sessionKey: legacySessionKey, + lastActiveSessionKey: legacyLastActiveSessionKey, + }; +} + function loadSessionToken(gatewayUrl: string): string { try { const storage = getSessionStorage(); @@ -144,12 +190,13 @@ export function loadSettings(): UiSettings { if (!raw) { return defaults; } - const parsed = JSON.parse(raw) as Partial; + const parsed = JSON.parse(raw) as PersistedUiSettings; const parsedGatewayUrl = typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim() ? parsed.gatewayUrl.trim() : defaults.gatewayUrl; const gatewayUrl = parsedGatewayUrl === pageDerivedUrl ? defaultUrl : parsedGatewayUrl; + const scopedSessionSelection = resolveScopedSessionSelection(gatewayUrl, parsed, defaults); const { theme, mode } = parseThemeSelection( (parsed as { theme?: unknown }).theme, (parsed as { themeMode?: unknown }).themeMode, @@ -158,15 +205,8 @@ export function loadSettings(): UiSettings { gatewayUrl, // Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load. token: loadSessionToken(gatewayUrl), - sessionKey: - typeof parsed.sessionKey === "string" && parsed.sessionKey.trim() - ? parsed.sessionKey.trim() - : defaults.sessionKey, - lastActiveSessionKey: - typeof parsed.lastActiveSessionKey === "string" && parsed.lastActiveSessionKey.trim() - ? parsed.lastActiveSessionKey.trim() - : (typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()) || - defaults.lastActiveSessionKey, + sessionKey: scopedSessionSelection.sessionKey, + lastActiveSessionKey: scopedSessionSelection.lastActiveSessionKey, theme, themeMode: mode, chatFocusMode: @@ -212,10 +252,33 @@ export function saveSettings(next: UiSettings) { function persistSettings(next: UiSettings) { persistSessionToken(next.gatewayUrl, next.token); + const scope = normalizeGatewayTokenScope(next.gatewayUrl); + let existingSessionsByGateway: Record = {}; + try { + const raw = localStorage.getItem(KEY); + if (raw) { + const parsed = JSON.parse(raw) as PersistedUiSettings; + if (parsed.sessionsByGateway && typeof parsed.sessionsByGateway === "object") { + existingSessionsByGateway = parsed.sessionsByGateway; + } + } + } catch { + // best-effort + } + const sessionsByGateway = Object.fromEntries( + [ + ...Object.entries(existingSessionsByGateway).filter(([key]) => key !== scope), + [ + scope, + { + sessionKey: next.sessionKey, + lastActiveSessionKey: next.lastActiveSessionKey, + }, + ], + ].slice(-MAX_SCOPED_SESSION_ENTRIES), + ); const persisted: PersistedUiSettings = { gatewayUrl: next.gatewayUrl, - sessionKey: next.sessionKey, - lastActiveSessionKey: next.lastActiveSessionKey, theme: next.theme, themeMode: next.themeMode, chatFocusMode: next.chatFocusMode, @@ -225,6 +288,7 @@ function persistSettings(next: UiSettings) { navCollapsed: next.navCollapsed, navWidth: next.navWidth, navGroupsCollapsed: next.navGroupsCollapsed, + sessionsByGateway, ...(next.locale ? { locale: next.locale } : {}), }; localStorage.setItem(KEY, JSON.stringify(persisted)); From f0202264d0de7ad345382b9008c5963bcefb01b7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 10:28:15 -0700 Subject: [PATCH 002/943] Gateway: scrub credentials from endpoint snapshots (#46799) * Gateway: scrub credentials from endpoint snapshots * Gateway: scrub raw endpoint credentials in snapshots * Gateway: preserve config redaction round-trips * Gateway: restore redacted endpoint URLs on apply --- CHANGELOG.md | 1 + src/channels/account-snapshot-fields.test.ts | 10 ++++ src/channels/account-snapshot-fields.ts | 3 +- src/config/redact-snapshot.test.ts | 49 ++++++++++++++++++++ src/config/redact-snapshot.ts | 44 ++++++++++++++++-- src/shared/net/url-userinfo.ts | 13 ++++++ 6 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 src/shared/net/url-userinfo.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5de1f1b05f5..05ddf446d28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146) - Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts. - Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`. +- Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc. - Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc. - Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. Thanks @vincentkoc. - Subagents/follow-ups: require the same controller ownership checks for `/subagents send` as other control actions, so leaf sessions cannot message nested child runs they do not control. Thanks @vincentkoc. diff --git a/src/channels/account-snapshot-fields.test.ts b/src/channels/account-snapshot-fields.test.ts index 6ccd03ccc21..b6cf92a7836 100644 --- a/src/channels/account-snapshot-fields.test.ts +++ b/src/channels/account-snapshot-fields.test.ts @@ -24,4 +24,14 @@ describe("projectSafeChannelAccountSnapshotFields", () => { signingSecretStatus: "configured_unavailable", // pragma: allowlist secret }); }); + + it("strips embedded credentials from baseUrl fields", () => { + const snapshot = projectSafeChannelAccountSnapshotFields({ + baseUrl: "https://bob:secret@chat.example.test", + }); + + expect(snapshot).toEqual({ + baseUrl: "https://chat.example.test/", + }); + }); }); diff --git a/src/channels/account-snapshot-fields.ts b/src/channels/account-snapshot-fields.ts index 72d745beac0..bfdc7ed6381 100644 --- a/src/channels/account-snapshot-fields.ts +++ b/src/channels/account-snapshot-fields.ts @@ -1,3 +1,4 @@ +import { stripUrlUserInfo } from "../shared/net/url-userinfo.js"; import type { ChannelAccountSnapshot } from "./plugins/types.core.js"; // Read-only status commands project a safe subset of account fields into snapshots @@ -203,7 +204,7 @@ export function projectSafeChannelAccountSnapshotFields( : {}), ...projectCredentialSnapshotFields(account), ...(readTrimmedString(record, "baseUrl") - ? { baseUrl: readTrimmedString(record, "baseUrl") } + ? { baseUrl: stripUrlUserInfo(readTrimmedString(record, "baseUrl")!) } : {}), ...(readBoolean(record, "allowUnmentionedGroups") !== undefined ? { allowUnmentionedGroups: readBoolean(record, "allowUnmentionedGroups") } diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index e173be34ec8..89aa4e1d121 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -163,6 +163,36 @@ describe("redactConfigSnapshot", () => { expect(result.config).toEqual(snapshot.config); }); + it("removes embedded credentials from URL-valued endpoint fields", () => { + const raw = `{ + models: { + providers: { + openai: { + baseUrl: "https://alice:secret@example.test/v1", + }, + }, + }, +}`; + const snapshot = makeSnapshot( + { + models: { + providers: { + openai: { + baseUrl: "https://alice:secret@example.test/v1", + }, + }, + }, + }, + raw, + ); + + const result = redactConfigSnapshot(snapshot); + const cfg = result.config as typeof snapshot.config; + expect(cfg.models.providers.openai.baseUrl).toBe(REDACTED_SENTINEL); + expect(result.raw).toContain(REDACTED_SENTINEL); + expect(result.raw).not.toContain("alice:secret@"); + }); + it("does not redact maxTokens-style fields", () => { const snapshot = makeSnapshot({ maxTokens: 16384, @@ -890,6 +920,25 @@ describe("redactConfigSnapshot", () => { }); describe("restoreRedactedValues", () => { + it("restores redacted URL endpoint fields on round-trip", () => { + const incoming = { + models: { + providers: { + openai: { baseUrl: REDACTED_SENTINEL }, + }, + }, + }; + const original = { + models: { + providers: { + openai: { baseUrl: "https://alice:secret@example.test/v1" }, + }, + }, + }; + const result = restoreRedactedValues(incoming, original, mainSchemaHints); + expect(result.models.providers.openai.baseUrl).toBe("https://alice:secret@example.test/v1"); + }); + it("restores sentinel values from original config", () => { const incoming = { gateway: { auth: { token: REDACTED_SENTINEL } }, diff --git a/src/config/redact-snapshot.ts b/src/config/redact-snapshot.ts index a80d1debb03..7c4eb5e50c5 100644 --- a/src/config/redact-snapshot.ts +++ b/src/config/redact-snapshot.ts @@ -1,5 +1,6 @@ import JSON5 from "json5"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { stripUrlUserInfo } from "../shared/net/url-userinfo.js"; import { replaceSensitiveValuesInRaw, shouldFallbackToStructuredRawRedaction, @@ -28,6 +29,10 @@ function isWholeObjectSensitivePath(path: string): boolean { return lowered.endsWith("serviceaccount") || lowered.endsWith("serviceaccountref"); } +function isUserInfoUrlPath(path: string): boolean { + return path.endsWith(".baseUrl") || path.endsWith(".httpUrl"); +} + function collectSensitiveStrings(value: unknown, values: string[]): void { if (typeof value === "string") { if (!isEnvVarPlaceholder(value)) { @@ -212,6 +217,14 @@ function redactObjectWithLookup( ) { // Keep primitives at explicitly-sensitive paths fully redacted. result[key] = REDACTED_SENTINEL; + } else if (typeof value === "string" && isUserInfoUrlPath(path)) { + const scrubbed = stripUrlUserInfo(value); + if (scrubbed !== value) { + values.push(value); + result[key] = REDACTED_SENTINEL; + } else { + result[key] = value; + } } break; } @@ -229,6 +242,14 @@ function redactObjectWithLookup( ) { result[key] = REDACTED_SENTINEL; values.push(value); + } else if (typeof value === "string" && isUserInfoUrlPath(path)) { + const scrubbed = stripUrlUserInfo(value); + if (scrubbed !== value) { + values.push(value); + result[key] = REDACTED_SENTINEL; + } else { + result[key] = value; + } } else if (typeof value === "object" && value !== null) { result[key] = redactObjectGuessing(value, path, values, hints); } @@ -293,6 +314,14 @@ function redactObjectGuessing( ) { collectSensitiveStrings(value, values); result[key] = REDACTED_SENTINEL; + } else if (typeof value === "string" && isUserInfoUrlPath(dotPath)) { + const scrubbed = stripUrlUserInfo(value); + if (scrubbed !== value) { + values.push(value); + result[key] = REDACTED_SENTINEL; + } else { + result[key] = value; + } } else if (typeof value === "object" && value !== null) { result[key] = redactObjectGuessing(value, dotPath, values, hints); } else { @@ -624,7 +653,10 @@ function restoreRedactedValuesWithLookup( for (const candidate of [path, wildcardPath]) { if (lookup.has(candidate)) { matched = true; - if (value === REDACTED_SENTINEL) { + if ( + value === REDACTED_SENTINEL && + (hints[candidate]?.sensitive === true || isUserInfoUrlPath(path)) + ) { result[key] = restoreOriginalValueOrThrow({ key, path: candidate, original: orig }); } else if (typeof value === "object" && value !== null) { result[key] = restoreRedactedValuesWithLookup(value, orig[key], lookup, candidate, hints); @@ -634,7 +666,11 @@ function restoreRedactedValuesWithLookup( } if (!matched) { const markedNonSensitive = isExplicitlyNonSensitivePath(hints, [path, wildcardPath]); - if (!markedNonSensitive && isSensitivePath(path) && value === REDACTED_SENTINEL) { + if ( + !markedNonSensitive && + value === REDACTED_SENTINEL && + (isSensitivePath(path) || isUserInfoUrlPath(path)) + ) { result[key] = restoreOriginalValueOrThrow({ key, path, original: orig }); } else if (typeof value === "object" && value !== null) { result[key] = restoreRedactedValuesGuessing(value, orig[key], path, hints); @@ -674,8 +710,8 @@ function restoreRedactedValuesGuessing( const wildcardPath = prefix ? `${prefix}.*` : "*"; if ( !isExplicitlyNonSensitivePath(hints, [path, wildcardPath]) && - isSensitivePath(path) && - value === REDACTED_SENTINEL + value === REDACTED_SENTINEL && + (isSensitivePath(path) || isUserInfoUrlPath(path)) ) { result[key] = restoreOriginalValueOrThrow({ key, path, original: orig }); } else if (typeof value === "object" && value !== null) { diff --git a/src/shared/net/url-userinfo.ts b/src/shared/net/url-userinfo.ts new file mode 100644 index 00000000000..d9374a3d4c2 --- /dev/null +++ b/src/shared/net/url-userinfo.ts @@ -0,0 +1,13 @@ +export function stripUrlUserInfo(value: string): string { + try { + const parsed = new URL(value); + if (!parsed.username && !parsed.password) { + return value; + } + parsed.username = ""; + parsed.password = ""; + return parsed.toString(); + } catch { + return value; + } +} From d88da9f5f8182932415c79b0c2a69007da794faa Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Sun, 15 Mar 2026 19:28:50 +0200 Subject: [PATCH 003/943] fix(config): avoid failing startup on implicit memory slot (#47494) * fix(config): avoid failing on implicit memory slot * fix(config): satisfy build for memory slot guard * docs(changelog): note implicit memory slot startup fix (#47494) --- CHANGELOG.md | 1 + src/config/config.plugin-validation.test.ts | 18 ++++++++++++++++++ src/config/validation.ts | 11 ++++++++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05ddf446d28..5653cc86e54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai - CLI: avoid loading provider discovery during startup model normalization. (#46522) Thanks @ItsAditya-xyz and @vincentkoc. - Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc. - ACP: require admin scope for mutating internal actions. (#46789) Thanks @tdjackey and @vincentkoc. +- Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman. ## 2026.3.13 diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 51d38b1a9af..f7f5539eb5a 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -173,6 +173,24 @@ describe("config plugin validation", () => { } }); + it("does not fail validation for the implicit default memory slot when plugins config is explicit", async () => { + const res = validateConfigObjectWithPlugins( + { + agents: { list: [{ id: "pi" }] }, + plugins: { + entries: { acpx: { enabled: true } }, + }, + }, + { + env: { + ...suiteEnv(), + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(suiteHome, "missing-bundled-plugins"), + }, + }, + ); + expect(res.ok).toBe(true); + }); + it("warns for removed legacy plugin ids instead of failing validation", async () => { const removedId = "google-antigravity-auth"; const res = validateInSuite({ diff --git a/src/config/validation.ts b/src/config/validation.ts index 686dbb0ed43..1486ea07182 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -528,8 +528,17 @@ function validateConfigObjectWithPluginsBase( } } + // The default memory slot is inferred; only a user-configured slot should block startup. + const pluginSlots = pluginsConfig?.slots; + const hasExplicitMemorySlot = + pluginSlots !== undefined && Object.prototype.hasOwnProperty.call(pluginSlots, "memory"); const memorySlot = normalizedPlugins.slots.memory; - if (typeof memorySlot === "string" && memorySlot.trim() && !knownIds.has(memorySlot)) { + if ( + hasExplicitMemorySlot && + typeof memorySlot === "string" && + memorySlot.trim() && + !knownIds.has(memorySlot) + ) { pushMissingPluginIssue("plugins.slots.memory", memorySlot); } From 756d9b57823217802b15c5b7d73a154fbd6bad85 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 10:29:31 -0700 Subject: [PATCH 004/943] CLI: lazy-load auth choice provider fallback (#47495) * CLI: lazy-load auth choice provider fallback * CLI: cover lazy auth choice provider fallback --- src/commands/auth-choice.preferred-provider.ts | 10 ++++++---- src/commands/auth-choice.test.ts | 8 ++++---- .../configure.gateway-auth.prompt-auth-config.test.ts | 2 +- src/commands/configure.gateway-auth.ts | 2 +- .../local/auth-choice.plugin-providers.ts | 4 ++-- src/wizard/onboarding.test.ts | 2 +- src/wizard/onboarding.ts | 2 +- 7 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index 959754625bc..49251a88f87 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -1,6 +1,4 @@ import type { OpenClawConfig } from "../config/config.js"; -import { resolveProviderPluginChoice } from "../plugins/provider-wizard.js"; -import { resolvePluginProviders } from "../plugins/providers.js"; import type { AuthChoice } from "./onboard-types.js"; const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { @@ -53,17 +51,21 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { vllm: "vllm", }; -export function resolvePreferredProviderForAuthChoice(params: { +export async function resolvePreferredProviderForAuthChoice(params: { choice: AuthChoice; config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; -}): string | undefined { +}): Promise { const preferred = PREFERRED_PROVIDER_BY_AUTH_CHOICE[params.choice]; if (preferred) { return preferred; } + const [{ resolveProviderPluginChoice }, { resolvePluginProviders }] = await Promise.all([ + import("../plugins/provider-wizard.js"), + import("../plugins/providers.js"), + ]); const providers = resolvePluginProviders({ config: params.config, workspaceDir: params.workspaceDir, diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index d5a59e48d46..e74c0e1c31f 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -1352,7 +1352,7 @@ describe("applyAuthChoice", () => { }); describe("resolvePreferredProviderForAuthChoice", () => { - it("maps known and unknown auth choices", () => { + it("maps known and unknown auth choices", async () => { const scenarios = [ { authChoice: "github-copilot" as const, expectedProvider: "github-copilot" }, { authChoice: "qwen-portal" as const, expectedProvider: "qwen-portal" }, @@ -1361,9 +1361,9 @@ describe("resolvePreferredProviderForAuthChoice", () => { { authChoice: "unknown" as AuthChoice, expectedProvider: undefined }, ] as const; for (const scenario of scenarios) { - expect(resolvePreferredProviderForAuthChoice({ choice: scenario.authChoice })).toBe( - scenario.expectedProvider, - ); + await expect( + resolvePreferredProviderForAuthChoice({ choice: scenario.authChoice }), + ).resolves.toBe(scenario.expectedProvider); } }); }); diff --git a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts index b27e52fcf7c..0657a77b3e1 100644 --- a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts +++ b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts @@ -23,7 +23,7 @@ vi.mock("./auth-choice-prompt.js", () => ({ vi.mock("./auth-choice.js", () => ({ applyAuthChoice: mocks.applyAuthChoice, - resolvePreferredProviderForAuthChoice: vi.fn(() => undefined), + resolvePreferredProviderForAuthChoice: vi.fn(async () => undefined), })); vi.mock("./model-picker.js", async (importActual) => { diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index 78bcc88ca5f..ca56ee25275 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -110,7 +110,7 @@ export async function promptAuthConfig( allowKeep: true, ignoreAllowlist: true, includeProviderPluginSetups: true, - preferredProvider: resolvePreferredProviderForAuthChoice({ + preferredProvider: await resolvePreferredProviderForAuthChoice({ choice: authChoice, config: next, }), diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts index 01007aa7aa2..d6e1440eb20 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts @@ -64,11 +64,11 @@ export async function applyNonInteractivePluginProviderChoice(params: { : undefined; const preferredProviderId = prefixedProviderId || - resolvePreferredProviderForAuthChoice({ + (await resolvePreferredProviderForAuthChoice({ choice: params.authChoice, config: params.nextConfig, workspaceDir, - }); + })); const resolutionConfig = buildIsolatedProviderResolutionConfig( params.nextConfig, preferredProviderId, diff --git a/src/wizard/onboarding.test.ts b/src/wizard/onboarding.test.ts index e6bbfd146fa..14c3183c323 100644 --- a/src/wizard/onboarding.test.ts +++ b/src/wizard/onboarding.test.ts @@ -11,7 +11,7 @@ import type { WizardPrompter, WizardSelectParams } from "./prompts.js"; const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ profiles: {} }))); const promptAuthChoiceGrouped = vi.hoisted(() => vi.fn(async () => "skip")); const applyAuthChoice = vi.hoisted(() => vi.fn(async (args) => ({ config: args.config }))); -const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(() => "openai")); +const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => "openai")); const warnIfModelConfigLooksOff = vi.hoisted(() => vi.fn(async () => {})); const applyPrimaryModel = vi.hoisted(() => vi.fn((cfg) => cfg)); const promptDefaultModel = vi.hoisted(() => vi.fn(async () => ({ config: null, model: null }))); diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index e8265efd49e..d2c35a022da 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -464,7 +464,7 @@ export async function runOnboardingWizard( allowKeep: true, ignoreAllowlist: true, includeProviderPluginSetups: true, - preferredProvider: resolvePreferredProviderForAuthChoice({ + preferredProvider: await resolvePreferredProviderForAuthChoice({ choice: authChoice, config: nextConfig, workspaceDir, From 132e45900904fc981e6ef04259eb37c72f6c165d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 10:43:03 -0700 Subject: [PATCH 005/943] fix(ci): config drift found and documented --- docs/.generated/config-baseline.json | 9636 ++++++++++++++--- docs/.generated/config-baseline.jsonl | 188 +- docs/gateway/configuration-reference.md | 7 + docs/gateway/configuration.md | 30 + docs/gateway/health.md | 9 + extensions/telegram/src/conversation-route.ts | 6 +- 6 files changed, 8203 insertions(+), 1673 deletions(-) diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index cf872fcd62d..f6f854b2946 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -8,7 +8,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP", "help": "ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.", "hasChildren": true @@ -20,7 +22,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "ACP Allowed Agents", "help": "Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.", "hasChildren": true @@ -42,7 +46,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Backend", "help": "Default ACP runtime backend id (for example: acpx). Must match a registered ACP runtime plugin backend.", "hasChildren": false @@ -54,7 +60,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Default Agent", "help": "Fallback ACP target agent id used when ACP spawns do not specify an explicit target.", "hasChildren": false @@ -76,7 +84,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Dispatch Enabled", "help": "Independent dispatch gate for ACP session turns (default: true). Set false to keep ACP commands available while blocking ACP turn execution.", "hasChildren": false @@ -88,7 +98,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Enabled", "help": "Global ACP feature gate. Keep disabled unless ACP runtime + policy are configured.", "hasChildren": false @@ -100,7 +112,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "ACP Max Concurrent Sessions", "help": "Maximum concurrently active ACP sessions across this gateway process.", "hasChildren": false @@ -122,7 +137,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Runtime Install Command", "help": "Optional operator install/setup command shown by `/acp install` and `/acp doctor` when ACP backend wiring is missing.", "hasChildren": false @@ -134,7 +151,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Runtime TTL (minutes)", "help": "Idle runtime TTL in minutes for ACP session workers before eligible cleanup.", "hasChildren": false @@ -146,7 +165,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Stream", "help": "ACP streaming projection controls for chunk sizing, metadata visibility, and deduped delivery behavior.", "hasChildren": true @@ -158,7 +179,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Stream Coalesce Idle (ms)", "help": "Coalescer idle flush window in milliseconds for ACP streamed text before block replies are emitted.", "hasChildren": false @@ -170,7 +193,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Stream Delivery Mode", "help": "ACP delivery style: live streams projected output incrementally, final_only buffers all projected ACP output until terminal turn events.", "hasChildren": false @@ -182,7 +207,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Stream Hidden Boundary Separator", "help": "Separator inserted before next visible assistant text when hidden ACP tool lifecycle events occurred (none|space|newline|paragraph). Default: paragraph.", "hasChildren": false @@ -194,7 +221,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "ACP Stream Max Chunk Chars", "help": "Maximum chunk size for ACP streamed block projection before splitting into multiple block replies.", "hasChildren": false @@ -206,7 +235,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "ACP Stream Max Output Chars", "help": "Maximum assistant output characters projected per ACP turn before truncation notice is emitted.", "hasChildren": false @@ -218,7 +249,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "ACP Stream Max Session Update Chars", "help": "Maximum characters for projected ACP session/update lines (tool/status updates).", "hasChildren": false @@ -230,7 +264,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Stream Repeat Suppression", "help": "When true (default), suppress repeated ACP status/tool projection lines in a turn while keeping raw ACP events unchanged.", "hasChildren": false @@ -242,7 +278,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Stream Tag Visibility", "help": "Per-sessionUpdate visibility overrides for ACP projection (for example usage_update, available_commands_update).", "hasChildren": true @@ -264,7 +302,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agents", "help": "Agent runtime configuration root covering defaults and explicit agent entries used for routing and execution context. Keep this section explicit so model/tool behavior stays predictable across multi-agent workflows.", "hasChildren": true @@ -276,7 +316,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent Defaults", "help": "Shared default settings inherited by agents unless overridden per entry in agents.list. Use defaults to enforce consistent baseline behavior and reduce duplicated per-agent configuration.", "hasChildren": true @@ -388,7 +430,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Bootstrap Max Chars", "help": "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", "hasChildren": false @@ -400,7 +444,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Bootstrap Prompt Truncation Warning", "help": "Inject agent-visible warning text when bootstrap files are truncated: \"off\", \"once\" (default), or \"always\".", "hasChildren": false @@ -412,7 +458,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Bootstrap Total Max Chars", "help": "Max total characters across all injected workspace bootstrap files (default: 150000).", "hasChildren": false @@ -424,7 +472,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "CLI Backends", "help": "Optional CLI backends for text-only fallback (claude-cli, etc.).", "hasChildren": true @@ -846,7 +896,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction", "help": "Compaction tuning for when context nears token limits, including history share, reserve headroom, and pre-compaction memory flush behavior. Use this when long-running sessions need stable continuity under tight context windows.", "hasChildren": true @@ -868,7 +920,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Identifier Instructions", "help": "Custom identifier-preservation instruction text used when identifierPolicy=\"custom\". Keep this explicit and safety-focused so compaction summaries do not rewrite opaque IDs, URLs, hosts, or ports.", "hasChildren": false @@ -880,7 +934,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Compaction Identifier Policy", "help": "Identifier-preservation policy for compaction summaries: \"strict\" prepends built-in opaque-identifier retention guidance (default), \"off\" disables this prefix, and \"custom\" uses identifierInstructions. Keep \"strict\" unless you have a specific compatibility need.", "hasChildren": false @@ -892,7 +948,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "label": "Compaction Keep Recent Tokens", "help": "Minimum token budget preserved from the most recent conversation window during compaction. Use higher values to protect immediate context continuity and lower values to keep more long-tail history.", "hasChildren": false @@ -904,7 +963,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Compaction Max History Share", "help": "Maximum fraction of total context budget allowed for retained history after compaction (range 0.1-0.9). Use lower shares for more generation headroom or higher shares for deeper historical continuity.", "hasChildren": false @@ -916,7 +977,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Memory Flush", "help": "Pre-compaction memory flush settings that run an agentic memory write before heavy compaction. Keep enabled for long sessions so salient context is persisted before aggressive trimming.", "hasChildren": true @@ -928,7 +991,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Memory Flush Enabled", "help": "Enables pre-compaction memory flush before the runtime performs stronger history reduction near token limits. Keep enabled unless you intentionally disable memory side effects in constrained environments.", "hasChildren": false @@ -936,11 +1001,16 @@ { "path": "agents.defaults.compaction.memoryFlush.forceFlushTranscriptBytes", "kind": "core", - "type": ["integer", "string"], + "type": [ + "integer", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Memory Flush Transcript Size Threshold", "help": "Forces pre-compaction memory flush when transcript file size reaches this threshold (bytes or strings like \"2mb\"). Use this to prevent long-session hangs even when token counters are stale; set to 0 to disable.", "hasChildren": false @@ -952,7 +1022,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Memory Flush Prompt", "help": "User-prompt template used for the pre-compaction memory flush turn when generating memory candidates. Use this only when you need custom extraction instructions beyond the default memory flush behavior.", "hasChildren": false @@ -964,7 +1036,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "label": "Compaction Memory Flush Soft Threshold", "help": "Threshold distance to compaction (in tokens) that triggers pre-compaction memory flush execution. Use earlier thresholds for safer persistence, or tighter thresholds for lower flush frequency.", "hasChildren": false @@ -976,7 +1051,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Memory Flush System Prompt", "help": "System-prompt override for the pre-compaction memory flush turn to control extraction style and safety constraints. Use carefully so custom instructions do not reduce memory quality or leak sensitive context.", "hasChildren": false @@ -988,7 +1065,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Mode", "help": "Compaction strategy mode: \"default\" uses baseline behavior, while \"safeguard\" applies stricter guardrails to preserve recent context. Keep \"default\" unless you observe aggressive history loss near limit boundaries.", "hasChildren": false @@ -1000,7 +1079,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Compaction Model Override", "help": "Optional provider/model override used only for compaction summarization. Set this when you want compaction to run on a different model than the session default, and leave it unset to keep using the primary agent model.", "hasChildren": false @@ -1012,7 +1093,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Post-Compaction Context Sections", "help": "AGENTS.md H2/H3 section names re-injected after compaction so the agent reruns critical startup guidance. Leave unset to use \"Session Startup\"/\"Red Lines\" with legacy fallback to \"Every Session\"/\"Safety\"; set to [] to disable reinjection entirely.", "hasChildren": true @@ -1032,10 +1115,16 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["off", "async", "await"], + "enumValues": [ + "off", + "async", + "await" + ], "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Post-Index Sync", "help": "Controls post-compaction session memory reindex mode: \"off\", \"async\", or \"await\" (default: \"async\"). Use \"await\" for strongest freshness, \"async\" for lower compaction latency, and \"off\" only when session-memory sync is handled elsewhere.", "hasChildren": false @@ -1047,7 +1136,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Quality Guard", "help": "Optional quality-audit retry settings for safeguard compaction summaries. Leave this disabled unless you explicitly want summary audits and one-shot regeneration on failed checks.", "hasChildren": true @@ -1059,7 +1150,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Quality Guard Enabled", "help": "Enables summary quality audits and regeneration retries for safeguard compaction. Default: false, so safeguard mode alone does not turn on retry behavior.", "hasChildren": false @@ -1071,7 +1164,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Compaction Quality Guard Max Retries", "help": "Maximum number of regeneration retries after a failed safeguard summary quality audit. Use small values to bound extra latency and token cost.", "hasChildren": false @@ -1083,7 +1178,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Preserve Recent Turns", "help": "Number of most recent user/assistant turns kept verbatim outside safeguard summarization (default: 3). Raise this to preserve exact recent dialogue context, or lower it to maximize compaction savings.", "hasChildren": false @@ -1095,7 +1192,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "label": "Compaction Reserve Tokens", "help": "Token headroom reserved for reply generation and tool output after compaction runs. Use higher reserves for verbose/tool-heavy sessions, and lower reserves when maximizing retained history matters more.", "hasChildren": false @@ -1107,11 +1207,28 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "label": "Compaction Reserve Token Floor", "help": "Minimum floor enforced for reserveTokens in Pi compaction paths (0 disables the floor guard). Use a non-zero floor to avoid over-aggressive compression under fluctuating token estimates.", "hasChildren": false }, + { + "path": "agents.defaults.compaction.timeoutSeconds", + "kind": "core", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "performance" + ], + "label": "Compaction Timeout (Seconds)", + "help": "Maximum time in seconds allowed for a single compaction operation before it is aborted (default: 900). Increase this for very large sessions that need more time to summarize, or decrease it to fail faster on unresponsive models.", + "hasChildren": false + }, { "path": "agents.defaults.contextPruning", "kind": "core", @@ -1329,7 +1446,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Embedded Pi", "help": "Embedded Pi runner hardening controls for how workspace-local Pi settings are trusted and applied in OpenClaw sessions.", "hasChildren": true @@ -1341,7 +1460,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Embedded Pi Project Settings Policy", "help": "How embedded Pi handles workspace-local `.pi/config/settings.json`: \"sanitize\" (default) strips shellPath/shellCommandPrefix, \"ignore\" disables project settings entirely, and \"trusted\" applies project settings as-is.", "hasChildren": false @@ -1353,7 +1474,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Envelope Elapsed", "help": "Include elapsed time in message envelopes (\"on\" or \"off\").", "hasChildren": false @@ -1365,7 +1488,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Envelope Timestamp", "help": "Include absolute timestamps in message envelopes (\"on\" or \"off\").", "hasChildren": false @@ -1377,7 +1502,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Envelope Timezone", "help": "Timezone for message envelopes (\"utc\", \"local\", \"user\", or an IANA timezone string).", "hasChildren": false @@ -1459,7 +1586,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "automation", "storage"], + "tags": [ + "access", + "automation", + "storage" + ], "label": "Heartbeat Direct Policy", "help": "Controls whether heartbeat delivery may target direct/DM chats: \"allow\" (default) permits DM delivery and \"block\" suppresses direct-target sends.", "hasChildren": false @@ -1541,7 +1672,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], + "tags": [ + "automation" + ], "label": "Heartbeat Suppress Tool Error Warnings", "help": "Suppress tool error warning payloads during heartbeat runs.", "hasChildren": false @@ -1553,8 +1686,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], - "help": "Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, zalouser, zalo, tlon, feishu, nextcloud-talk, msteams, bluebubbles, synology-chat, mattermost, twitch, matrix, nostr.", + "tags": [ + "automation" + ], + "help": "Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.", "hasChildren": false }, { @@ -1584,7 +1719,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Human Delay Max (ms)", "help": "Maximum delay in ms for custom humanDelay (default: 2500).", "hasChildren": false @@ -1596,7 +1733,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Human Delay Min (ms)", "help": "Minimum delay in ms for custom humanDelay (default: 800).", "hasChildren": false @@ -1608,7 +1747,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Human Delay Mode", "help": "Delay style for block replies (\"off\", \"natural\", \"custom\").", "hasChildren": false @@ -1620,7 +1761,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance"], + "tags": [ + "media", + "performance" + ], "label": "Image Max Dimension (px)", "help": "Max image side length in pixels when sanitizing transcript/tool-result image payloads (default: 1200).", "hasChildren": false @@ -1628,7 +1772,10 @@ { "path": "agents.defaults.imageModel", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -1642,7 +1789,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "models", "reliability"], + "tags": [ + "media", + "models", + "reliability" + ], "label": "Image Model Fallbacks", "help": "Ordered fallback image models (provider/model).", "hasChildren": true @@ -1664,7 +1815,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "models"], + "tags": [ + "media", + "models" + ], "label": "Image Model", "help": "Optional image model (provider/model) used when the primary model lacks image input.", "hasChildren": false @@ -1696,7 +1850,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search", "help": "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", "hasChildren": true @@ -1718,7 +1874,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Memory Search Embedding Cache", "help": "Caches computed chunk embeddings in SQLite so reindexing and incremental updates run faster (default: true). Keep this enabled unless investigating cache correctness or minimizing disk usage.", "hasChildren": false @@ -1730,7 +1888,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "Memory Search Embedding Cache Max Entries", "help": "Sets a best-effort upper bound on cached embeddings kept in SQLite for memory search. Use this when controlling disk growth matters more than peak reindex speed.", "hasChildren": false @@ -1752,7 +1913,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Chunk Overlap Tokens", "help": "Token overlap between adjacent memory chunks to preserve context continuity near split boundaries. Use modest overlap to reduce boundary misses without inflating index size too aggressively.", "hasChildren": false @@ -1764,7 +1927,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "label": "Memory Chunk Tokens", "help": "Chunk size in tokens used when splitting memory sources before embedding/indexing. Increase for broader context per chunk, or lower to improve precision on pinpoint lookups.", "hasChildren": false @@ -1776,7 +1942,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable Memory Search", "help": "Master toggle for memory search indexing and retrieval behavior on this agent profile. Keep enabled for semantic recall, and disable when you want fully stateless responses.", "hasChildren": false @@ -1798,7 +1966,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "security", "storage"], + "tags": [ + "advanced", + "security", + "storage" + ], "label": "Memory Search Session Index (Experimental)", "help": "Indexes session transcripts into memory search so responses can reference prior chat turns. Keep this off unless transcript recall is needed, because indexing cost and storage usage both increase.", "hasChildren": false @@ -1810,7 +1982,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Extra Memory Paths", "help": "Adds extra directories or .md files to the memory index beyond default memory files. Use this when key reference docs live elsewhere in your repo; when multimodal memory is enabled, matching image/audio files under these paths are also eligible for indexing.", "hasChildren": true @@ -1832,7 +2006,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["reliability"], + "tags": [ + "reliability" + ], "label": "Memory Search Fallback", "help": "Backup provider used when primary embeddings fail: \"openai\", \"gemini\", \"voyage\", \"mistral\", \"ollama\", \"local\", or \"none\". Set a real fallback for production reliability; use \"none\" only if you prefer explicit failures.", "hasChildren": false @@ -1864,7 +2040,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Local Embedding Model Path", "help": "Specifies the local embedding model source for local memory search, such as a GGUF file path or `hf:` URI. Use this only when provider is `local`, and verify model compatibility before large index rebuilds.", "hasChildren": false @@ -1876,7 +2054,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Memory Search Model", "help": "Embedding model override used by the selected memory provider when a non-default model is required. Set this only when you need explicit recall quality/cost tuning beyond provider defaults.", "hasChildren": false @@ -1888,7 +2068,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Multimodal", "help": "Optional multimodal memory settings for indexing image and audio files from configured extra paths. Keep this off unless your embedding model explicitly supports cross-modal embeddings, and set `memorySearch.fallback` to \"none\" while it is enabled. Matching files are uploaded to the configured remote embedding provider during indexing.", "hasChildren": true @@ -1900,7 +2082,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable Memory Search Multimodal", "help": "Enables image/audio memory indexing from extraPaths. This currently requires Gemini embedding-2, keeps the default memory roots Markdown-only, disables memory-search fallback providers, and uploads matching binary content to the configured remote embedding provider.", "hasChildren": false @@ -1912,7 +2096,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "Memory Search Multimodal Max File Bytes", "help": "Sets the maximum bytes allowed per multimodal file before it is skipped during memory indexing. Use this to cap upload cost and indexing latency, or raise it for short high-quality audio clips.", "hasChildren": false @@ -1924,7 +2111,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Multimodal Modalities", "help": "Selects which multimodal file types are indexed from extraPaths: \"image\", \"audio\", or \"all\". Keep this narrow to avoid indexing large binary corpora unintentionally.", "hasChildren": true @@ -1946,7 +2135,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Output Dimensionality", "help": "Gemini embedding-2 only: chooses the output vector size for memory embeddings. Use 768, 1536, or 3072 (default), and expect a full reindex when you change it because stored vector dimensions must stay consistent.", "hasChildren": false @@ -1958,7 +2149,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Provider", "help": "Selects the embedding backend used to build/query memory vectors: \"openai\", \"gemini\", \"voyage\", \"mistral\", \"ollama\", or \"local\". Keep your most reliable provider here and configure fallback for resilience.", "hasChildren": false @@ -1990,7 +2183,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Hybrid Candidate Multiplier", "help": "Expands the candidate pool before reranking (default: 4). Raise this for better recall on noisy corpora, but expect more compute and slightly slower searches.", "hasChildren": false @@ -2002,7 +2197,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Hybrid", "help": "Combines BM25 keyword matching with vector similarity for better recall on mixed exact + semantic queries. Keep enabled unless you are isolating ranking behavior for troubleshooting.", "hasChildren": false @@ -2024,7 +2221,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search MMR Re-ranking", "help": "Adds MMR reranking to diversify results and reduce near-duplicate snippets in a single answer window. Enable when recall looks repetitive; keep off for strict score ordering.", "hasChildren": false @@ -2036,7 +2235,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search MMR Lambda", "help": "Sets MMR relevance-vs-diversity balance (0 = most diverse, 1 = most relevant, default: 0.7). Lower values reduce repetition; higher values keep tightly relevant but may duplicate.", "hasChildren": false @@ -2058,7 +2259,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Temporal Decay", "help": "Applies recency decay so newer memory can outrank older memory when scores are close. Enable when timeliness matters; keep off for timeless reference knowledge.", "hasChildren": false @@ -2070,7 +2273,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Temporal Decay Half-life (Days)", "help": "Controls how fast older memory loses rank when temporal decay is enabled (half-life in days, default: 30). Lower values prioritize recent context more aggressively.", "hasChildren": false @@ -2082,7 +2287,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Text Weight", "help": "Controls how strongly BM25 keyword relevance influences hybrid ranking (0-1). Increase for exact-term matching; decrease when semantic matches should rank higher.", "hasChildren": false @@ -2094,7 +2301,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Vector Weight", "help": "Controls how strongly semantic similarity influences hybrid ranking (0-1). Increase when paraphrase matching matters more than exact terms; decrease for stricter keyword emphasis.", "hasChildren": false @@ -2106,7 +2315,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Memory Search Max Results", "help": "Maximum number of memory hits returned from search before downstream reranking and prompt injection. Raise for broader recall, or lower for tighter prompts and faster responses.", "hasChildren": false @@ -2118,7 +2329,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Min Score", "help": "Minimum relevance score threshold for including memory results in final recall output. Increase to reduce weak/noisy matches, or lower when you need more permissive retrieval.", "hasChildren": false @@ -2136,11 +2349,17 @@ { "path": "agents.defaults.memorySearch.remote.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "label": "Remote Embedding API Key", "help": "Supplies a dedicated API key for remote embedding calls used by memory indexing and query-time embeddings. Use this when memory embeddings should use different credentials than global defaults or environment variables.", "hasChildren": true @@ -2182,7 +2401,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Remote Embedding Base URL", "help": "Overrides the embedding API endpoint, such as an OpenAI-compatible proxy or custom Gemini base URL. Use this only when routing through your own gateway or vendor endpoint; keep provider defaults otherwise.", "hasChildren": false @@ -2204,7 +2425,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Remote Batch Concurrency", "help": "Limits how many embedding batch jobs run at the same time during indexing (default: 2). Increase carefully for faster bulk indexing, but watch provider rate limits and queue errors.", "hasChildren": false @@ -2216,7 +2439,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Remote Batch Embedding Enabled", "help": "Enables provider batch APIs for embedding jobs when supported (OpenAI/Gemini), improving throughput on larger index runs. Keep this enabled unless debugging provider batch failures or running very small workloads.", "hasChildren": false @@ -2228,7 +2453,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Remote Batch Poll Interval (ms)", "help": "Controls how often the system polls provider APIs for batch job status in milliseconds (default: 2000). Use longer intervals to reduce API chatter, or shorter intervals for faster completion detection.", "hasChildren": false @@ -2240,7 +2467,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Remote Batch Timeout (min)", "help": "Sets the maximum wait time for a full embedding batch operation in minutes (default: 60). Increase for very large corpora or slower providers, and lower it to fail fast in automation-heavy flows.", "hasChildren": false @@ -2252,7 +2481,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Remote Batch Wait for Completion", "help": "Waits for batch embedding jobs to fully finish before the indexing operation completes. Keep this enabled for deterministic indexing state; disable only if you accept delayed consistency.", "hasChildren": false @@ -2264,7 +2495,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Remote Embedding Headers", "help": "Adds custom HTTP headers to remote embedding requests, merged with provider defaults. Use this for proxy auth and tenant routing headers, and keep values minimal to avoid leaking sensitive metadata.", "hasChildren": true @@ -2286,7 +2519,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Sources", "help": "Chooses which sources are indexed: \"memory\" reads MEMORY.md + memory files, and \"sessions\" includes transcript history. Keep [\"memory\"] unless you need recall from prior chat transcripts.", "hasChildren": true @@ -2328,7 +2563,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Memory Search Index Path", "help": "Sets where the SQLite memory index is stored on disk for each agent. Keep the default `~/.openclaw/memory/{agentId}.sqlite` unless you need custom storage placement or backup policy alignment.", "hasChildren": false @@ -2350,7 +2587,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Memory Search Vector Index", "help": "Enables the sqlite-vec extension used for vector similarity queries in memory search (default: true). Keep this enabled for normal semantic recall; disable only for debugging or fallback-only operation.", "hasChildren": false @@ -2362,7 +2601,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Memory Search Vector Extension Path", "help": "Overrides the auto-discovered sqlite-vec extension library path (`.dylib`, `.so`, or `.dll`). Use this when your runtime cannot find sqlite-vec automatically or you pin a known-good build.", "hasChildren": false @@ -2394,7 +2635,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Index on Search (Lazy)", "help": "Uses lazy sync by scheduling reindex on search after content changes are detected. Keep enabled for lower idle overhead, or disable if you require pre-synced indexes before any query.", "hasChildren": false @@ -2406,7 +2649,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "storage"], + "tags": [ + "automation", + "storage" + ], "label": "Index on Session Start", "help": "Triggers a memory index sync when a session starts so early turns see fresh memory content. Keep enabled when startup freshness matters more than initial turn latency.", "hasChildren": false @@ -2428,7 +2674,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Delta Bytes", "help": "Requires at least this many newly appended bytes before session transcript changes trigger reindex (default: 100000). Increase to reduce frequent small reindexes, or lower for faster transcript freshness.", "hasChildren": false @@ -2440,7 +2688,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Delta Messages", "help": "Requires at least this many appended transcript messages before reindex is triggered (default: 50). Lower this for near-real-time transcript recall, or raise it to reduce indexing churn.", "hasChildren": false @@ -2452,7 +2702,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Force Reindex After Compaction", "help": "Forces a session memory-search reindex after compaction-triggered transcript updates (default: true). Keep enabled when compacted summaries must be immediately searchable, or disable to reduce write-time indexing pressure.", "hasChildren": false @@ -2464,7 +2716,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Watch Memory Files", "help": "Watches memory files and schedules index updates from file-change events (chokidar). Enable for near-real-time freshness; disable on very large workspaces if watch churn is too noisy.", "hasChildren": false @@ -2476,7 +2730,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "performance"], + "tags": [ + "automation", + "performance" + ], "label": "Memory Watch Debounce (ms)", "help": "Debounce window in milliseconds for coalescing rapid file-watch events before reindex runs. Increase to reduce churn on frequently-written files, or lower for faster freshness.", "hasChildren": false @@ -2484,7 +2741,10 @@ { "path": "agents.defaults.model", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -2498,7 +2758,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models", "reliability"], + "tags": [ + "models", + "reliability" + ], "label": "Model Fallbacks", "help": "Ordered fallback models (provider/model). Used when the primary model fails.", "hasChildren": true @@ -2520,7 +2783,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Primary Model", "help": "Primary model (provider/model).", "hasChildren": false @@ -2532,7 +2797,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Models", "help": "Configured model catalog (keys are full provider/model IDs).", "hasChildren": true @@ -2593,7 +2860,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "PDF Max Size (MB)", "help": "Maximum PDF file size in megabytes for the PDF tool (default: 10).", "hasChildren": false @@ -2605,7 +2874,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "PDF Max Pages", "help": "Maximum number of PDF pages to process for the PDF tool (default: 20).", "hasChildren": false @@ -2613,7 +2884,10 @@ { "path": "agents.defaults.pdfModel", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -2627,7 +2901,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["reliability"], + "tags": [ + "reliability" + ], "label": "PDF Model Fallbacks", "help": "Ordered fallback PDF models (provider/model).", "hasChildren": true @@ -2649,7 +2925,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "PDF Model", "help": "Optional PDF model (provider/model) for the PDF analysis tool. Defaults to imageModel, then session model.", "hasChildren": false @@ -2661,7 +2939,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Repo Root", "help": "Optional repository root shown in the system prompt runtime line (overrides auto-detect).", "hasChildren": false @@ -2753,7 +3033,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Sandbox Browser CDP Source Port Range", "help": "Optional CIDR allowlist for container-edge CDP ingress (for example 172.21.0.1/32).", "hasChildren": false @@ -2815,7 +3097,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Sandbox Browser Network", "help": "Docker network for sandbox browser containers (default: openclaw-sandbox-browser). Avoid bridge if you need stricter isolation.", "hasChildren": false @@ -2927,7 +3211,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "advanced", "security", "storage"], + "tags": [ + "access", + "advanced", + "security", + "storage" + ], "label": "Sandbox Docker Allow Container Namespace Join", "help": "DANGEROUS break-glass override that allows sandbox Docker network mode container:. This joins another container namespace and weakens sandbox isolation.", "hasChildren": false @@ -3025,7 +3314,10 @@ { "path": "agents.defaults.sandbox.docker.memory", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -3035,7 +3327,10 @@ { "path": "agents.defaults.sandbox.docker.memorySwap", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -3124,7 +3419,11 @@ { "path": "agents.defaults.sandbox.docker.ulimits.*", "kind": "core", - "type": ["number", "object", "string"], + "type": [ + "number", + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -3334,7 +3633,10 @@ { "path": "agents.defaults.subagents.model", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -3468,7 +3770,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Workspace", "help": "Default workspace path exposed to agent runtime tools for filesystem context and repo-aware behavior. Set this explicitly when running from wrappers so path resolution stays deterministic.", "hasChildren": false @@ -3480,7 +3784,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent List", "help": "Explicit list of configured agents with IDs and optional overrides for model, tools, identity, and workspace. Keep IDs stable over time so bindings, approvals, and session routing remain deterministic.", "hasChildren": true @@ -3632,7 +3938,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "automation", "storage"], + "tags": [ + "access", + "automation", + "storage" + ], "label": "Heartbeat Direct Policy", "help": "Per-agent override for heartbeat direct/DM delivery policy; use \"block\" for agents that should only send heartbeat alerts to non-DM destinations.", "hasChildren": false @@ -3714,7 +4024,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], + "tags": [ + "automation" + ], "label": "Agent Heartbeat Suppress Tool Error Warnings", "help": "Suppress tool error warning payloads during heartbeat runs.", "hasChildren": false @@ -3726,8 +4038,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], - "help": "Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, zalouser, zalo, tlon, feishu, nextcloud-talk, msteams, bluebubbles, synology-chat, mattermost, twitch, matrix, nostr.", + "tags": [ + "automation" + ], + "help": "Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.", "hasChildren": false }, { @@ -3807,7 +4121,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Identity Avatar", "help": "Agent avatar (workspace-relative path, http(s) URL, or data URI).", "hasChildren": false @@ -4235,11 +4551,17 @@ { "path": "agents.list.*.memorySearch.remote.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "hasChildren": true }, { @@ -4545,7 +4867,10 @@ { "path": "agents.list.*.model", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -4618,7 +4943,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent Runtime", "help": "Optional runtime descriptor for this agent. Use embedded for default OpenClaw execution or acp for external ACP harness defaults.", "hasChildren": true @@ -4630,7 +4957,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent ACP Runtime", "help": "ACP runtime defaults for this agent when runtime.type=acp. Binding-level ACP overrides still take precedence per conversation.", "hasChildren": true @@ -4642,7 +4971,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent ACP Harness Agent", "help": "Optional ACP harness agent id to use for this OpenClaw agent (for example codex, claude).", "hasChildren": false @@ -4654,7 +4985,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent ACP Backend", "help": "Optional ACP backend override for this agent's ACP sessions (falls back to global acp.backend).", "hasChildren": false @@ -4666,7 +4999,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent ACP Working Directory", "help": "Optional default working directory for this agent's ACP sessions.", "hasChildren": false @@ -4676,10 +5011,15 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["persistent", "oneshot"], + "enumValues": [ + "persistent", + "oneshot" + ], "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent ACP Mode", "help": "Optional ACP session mode default for this agent (persistent or oneshot).", "hasChildren": false @@ -4691,7 +5031,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent Runtime Type", "help": "Runtime type for this agent: \"embedded\" (default OpenClaw runtime) or \"acp\" (ACP harness defaults).", "hasChildren": false @@ -4783,7 +5125,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Agent Sandbox Browser CDP Source Port Range", "help": "Per-agent override for CDP source CIDR allowlist.", "hasChildren": false @@ -4845,7 +5189,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Agent Sandbox Browser Network", "help": "Per-agent override for sandbox browser Docker network.", "hasChildren": false @@ -4957,7 +5303,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "advanced", "security", "storage"], + "tags": [ + "access", + "advanced", + "security", + "storage" + ], "label": "Agent Sandbox Docker Allow Container Namespace Join", "help": "Per-agent DANGEROUS override for container namespace joins in sandbox Docker network mode.", "hasChildren": false @@ -5055,7 +5406,10 @@ { "path": "agents.list.*.sandbox.docker.memory", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -5065,7 +5419,10 @@ { "path": "agents.list.*.sandbox.docker.memorySwap", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -5154,7 +5511,11 @@ { "path": "agents.list.*.sandbox.docker.ulimits.*", "kind": "core", - "type": ["number", "object", "string"], + "type": [ + "number", + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -5298,7 +5659,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent Skill Filter", "help": "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", "hasChildren": true @@ -5346,7 +5709,10 @@ { "path": "agents.list.*.subagents.model", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -5430,7 +5796,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Agent Tool Allowlist Additions", "help": "Per-agent additive allowlist for tools on top of global and profile policy. Keep narrow to avoid accidental privilege expansion on specialized agents.", "hasChildren": true @@ -5452,7 +5820,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent Tool Policy by Provider", "help": "Per-agent provider-specific tool policy overrides for channel-scoped capability control. Use this when a single agent needs tighter restrictions on one provider than others.", "hasChildren": true @@ -5590,7 +5960,10 @@ { "path": "agents.list.*.tools.elevated.allowFrom.*.*", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -5682,7 +6055,11 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["off", "on-miss", "always"], + "enumValues": [ + "off", + "on-miss", + "always" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -5713,7 +6090,11 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["sandbox", "gateway", "node"], + "enumValues": [ + "sandbox", + "gateway", + "node" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -5894,7 +6275,11 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["deny", "allowlist", "full"], + "enumValues": [ + "deny", + "allowlist", + "full" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -6037,7 +6422,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Agent Tool Profile", "help": "Per-agent override for tool profile selection when one agent needs a different capability baseline. Use this sparingly so policy differences across agents stay intentional and reviewable.", "hasChildren": false @@ -6139,7 +6526,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Approvals", "help": "Approval routing controls for forwarding exec approval requests to chat destinations outside the originating session. Keep this disabled unless operators need explicit out-of-band approval visibility.", "hasChildren": true @@ -6151,7 +6540,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Exec Approval Forwarding", "help": "Groups exec-approval forwarding behavior including enablement, routing mode, filters, and explicit targets. Configure here when approval prompts must reach operational channels instead of only the origin thread.", "hasChildren": true @@ -6163,7 +6554,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Approval Agent Filter", "help": "Optional allowlist of agent IDs eligible for forwarded approvals, for example `[\"primary\", \"ops-agent\"]`. Use this to limit forwarding blast radius and avoid notifying channels for unrelated agents.", "hasChildren": true @@ -6185,7 +6578,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Forward Exec Approvals", "help": "Enables forwarding of exec approval requests to configured delivery destinations (default: false). Keep disabled in low-risk setups and enable only when human approval responders need channel-visible prompts.", "hasChildren": false @@ -6197,7 +6592,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Approval Forwarding Mode", "help": "Controls where approval prompts are sent: \"session\" uses origin chat, \"targets\" uses configured targets, and \"both\" sends to both paths. Use \"session\" as baseline and expand only when operational workflow requires redundancy.", "hasChildren": false @@ -6209,7 +6606,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Approval Session Filter", "help": "Optional session-key filters matched as substring or regex-style patterns, for example `[\"discord:\", \"^agent:ops:\"]`. Use narrow patterns so only intended approval contexts are forwarded to shared destinations.", "hasChildren": true @@ -6231,7 +6630,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Approval Forwarding Targets", "help": "Explicit delivery targets used when forwarding mode includes targets, each with channel and destination details. Keep target lists least-privilege and validate each destination before enabling broad forwarding.", "hasChildren": true @@ -6253,7 +6654,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Approval Target Account ID", "help": "Optional account selector for multi-account channel setups when approvals must route through a specific account context. Use this only when the target channel has multiple configured identities.", "hasChildren": false @@ -6265,7 +6668,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Approval Target Channel", "help": "Channel/provider ID used for forwarded approval delivery, such as discord, slack, or a plugin channel id. Use valid channel IDs only so approvals do not silently fail due to unknown routes.", "hasChildren": false @@ -6273,11 +6678,16 @@ { "path": "approvals.exec.targets.*.threadId", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Approval Target Thread ID", "help": "Optional thread/topic target for channels that support threaded delivery of forwarded approvals. Use this to keep approval traffic contained in operational threads instead of main channels.", "hasChildren": false @@ -6289,7 +6699,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Approval Target Destination", "help": "Destination identifier inside the target channel (channel ID, user ID, or thread root depending on provider). Verify semantics per provider because destination format differs across channel integrations.", "hasChildren": false @@ -6301,7 +6713,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Audio", "help": "Global audio ingestion settings used before higher-level tools process speech or media content. Configure this when you need deterministic transcription behavior for voice notes and clips.", "hasChildren": true @@ -6313,7 +6727,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Audio Transcription", "help": "Command-based transcription settings for converting audio files into text before agent handling. Keep a simple, deterministic command path here so failures are easy to diagnose in logs.", "hasChildren": true @@ -6325,7 +6741,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Audio Transcription Command", "help": "Executable + args used to transcribe audio (first token must be a safe binary/path), for example `[\"whisper-cli\", \"--model\", \"small\", \"{input}\"]`. Prefer a pinned command so runtime environments behave consistently.", "hasChildren": true @@ -6347,7 +6765,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance"], + "tags": [ + "media", + "performance" + ], "label": "Audio Transcription Timeout (sec)", "help": "Maximum time allowed for the transcription command to finish before it is aborted. Increase this for longer recordings, and keep it tight in latency-sensitive deployments.", "hasChildren": false @@ -6359,7 +6780,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Auth", "help": "Authentication profile root used for multi-profile provider credentials and cooldown-based failover ordering. Keep profiles minimal and explicit so automatic failover behavior stays auditable.", "hasChildren": true @@ -6371,7 +6794,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "auth"], + "tags": [ + "access", + "auth" + ], "label": "Auth Cooldowns", "help": "Cooldown/backoff controls for temporary profile suppression after billing-related failures and retry windows. Use these to prevent rapid re-selection of profiles that are still blocked.", "hasChildren": true @@ -6383,7 +6809,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "auth", "reliability"], + "tags": [ + "access", + "auth", + "reliability" + ], "label": "Billing Backoff (hours)", "help": "Base backoff (hours) when a profile fails due to billing/insufficient credits (default: 5).", "hasChildren": false @@ -6395,7 +6825,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "auth", "reliability"], + "tags": [ + "access", + "auth", + "reliability" + ], "label": "Billing Backoff Overrides", "help": "Optional per-provider overrides for billing backoff (hours).", "hasChildren": true @@ -6417,7 +6851,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "auth", "performance"], + "tags": [ + "access", + "auth", + "performance" + ], "label": "Billing Backoff Cap (hours)", "help": "Cap (hours) for billing backoff (default: 24).", "hasChildren": false @@ -6429,7 +6867,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "auth"], + "tags": [ + "access", + "auth" + ], "label": "Failover Window (hours)", "help": "Failure window (hours) for backoff counters (default: 24).", "hasChildren": false @@ -6441,7 +6882,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "auth"], + "tags": [ + "access", + "auth" + ], "label": "Auth Profile Order", "help": "Ordered auth profile IDs per provider (used for automatic failover).", "hasChildren": true @@ -6473,7 +6917,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "auth", "storage"], + "tags": [ + "access", + "auth", + "storage" + ], "label": "Auth Profiles", "help": "Named auth profiles (provider + mode + optional email).", "hasChildren": true @@ -6525,7 +6973,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Bindings", "help": "Top-level binding rules for routing and persistent ACP conversation ownership. Use type=route for normal routing and type=acp for persistent ACP harness bindings.", "hasChildren": true @@ -6547,7 +6997,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Binding Overrides", "help": "Optional per-binding ACP overrides for bindings[].type=acp. This layer overrides agents.list[].runtime.acp defaults for the matched conversation.", "hasChildren": true @@ -6559,7 +7011,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Binding Backend", "help": "ACP backend override for this binding (falls back to agent runtime ACP backend, then global acp.backend).", "hasChildren": false @@ -6571,7 +7025,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Binding Working Directory", "help": "Working directory override for ACP sessions created from this binding.", "hasChildren": false @@ -6583,7 +7039,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Binding Label", "help": "Human-friendly label for ACP status/diagnostics in this bound conversation.", "hasChildren": false @@ -6593,10 +7051,15 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["persistent", "oneshot"], + "enumValues": [ + "persistent", + "oneshot" + ], "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Binding Mode", "help": "ACP session mode override for this binding (persistent or oneshot).", "hasChildren": false @@ -6608,7 +7071,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Agent ID", "help": "Target agent ID that receives traffic when the corresponding binding match rule is satisfied. Use valid configured agent IDs only so routing does not fail at runtime.", "hasChildren": false @@ -6630,7 +7095,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Match Rule", "help": "Match rule object for deciding when a binding applies, including channel and optional account/peer constraints. Keep rules narrow to avoid accidental agent takeover across contexts.", "hasChildren": true @@ -6642,7 +7109,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Account ID", "help": "Optional account selector for multi-account channel setups so the binding applies only to one identity. Use this when account scoping is required for the route and leave unset otherwise.", "hasChildren": false @@ -6654,7 +7123,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Channel", "help": "Channel/provider identifier this binding applies to, such as `telegram`, `discord`, or a plugin channel ID. Use the configured channel key exactly so binding evaluation works reliably.", "hasChildren": false @@ -6666,7 +7137,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Guild ID", "help": "Optional Discord-style guild/server ID constraint for binding evaluation in multi-server deployments. Use this when the same peer identifiers can appear across different guilds.", "hasChildren": false @@ -6678,7 +7151,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Peer Match", "help": "Optional peer matcher for specific conversations including peer kind and peer id. Use this when only one direct/group/channel target should be pinned to an agent.", "hasChildren": true @@ -6690,7 +7165,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Peer ID", "help": "Conversation identifier used with peer matching, such as a chat ID, channel ID, or group ID from the provider. Keep this exact to avoid silent non-matches.", "hasChildren": false @@ -6702,7 +7179,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Peer Kind", "help": "Peer conversation type: \"direct\", \"group\", \"channel\", or legacy \"dm\" (deprecated alias for direct). Prefer \"direct\" for new configs and keep kind aligned with channel semantics.", "hasChildren": false @@ -6714,7 +7193,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Roles", "help": "Optional role-based filter list used by providers that attach roles to chat context. Use this to route privileged or operational role traffic to specialized agents.", "hasChildren": true @@ -6736,7 +7217,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Team ID", "help": "Optional team/workspace ID constraint used by providers that scope chats under teams. Add this when you need bindings isolated to one workspace context.", "hasChildren": false @@ -6748,7 +7231,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Type", "help": "Binding kind. Use \"route\" (or omit for legacy route entries) for normal routing, and \"acp\" for persistent ACP conversation bindings.", "hasChildren": false @@ -6760,7 +7245,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Broadcast", "help": "Broadcast routing map for sending the same outbound message to multiple peer IDs per source conversation. Keep this minimal and audited because one source can fan out to many destinations.", "hasChildren": true @@ -6772,7 +7259,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Broadcast Destination List", "help": "Per-source broadcast destination list where each key is a source peer ID and the value is an array of destination peer IDs. Keep lists intentional to avoid accidental message amplification.", "hasChildren": true @@ -6792,10 +7281,15 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["parallel", "sequential"], + "enumValues": [ + "parallel", + "sequential" + ], "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Broadcast Strategy", "help": "Delivery order for broadcast fan-out: \"parallel\" sends to all targets concurrently, while \"sequential\" sends one-by-one. Use \"parallel\" for speed and \"sequential\" for stricter ordering/backpressure control.", "hasChildren": false @@ -6807,7 +7301,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser", "help": "Browser runtime controls for local or remote CDP attachment, profile routing, and screenshot/snapshot behavior. Keep defaults unless your automation workflow requires custom browser transport settings.", "hasChildren": true @@ -6819,7 +7315,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser Attach-only Mode", "help": "Restricts browser mode to attach-only behavior without starting local browser processes. Use this when all browser sessions are externally managed by a remote CDP provider.", "hasChildren": false @@ -6831,7 +7329,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser CDP Port Range Start", "help": "Starting local CDP port used for auto-allocated browser profile ports. Increase this when host-level port defaults conflict with other local services.", "hasChildren": false @@ -6843,7 +7343,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser CDP URL", "help": "Remote CDP websocket URL used to attach to an externally managed browser instance. Use this for centralized browser hosts and keep URL access restricted to trusted network paths.", "hasChildren": false @@ -6855,7 +7357,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser Accent Color", "help": "Default accent color used for browser profile/UI cues where colored identity hints are displayed. Use consistent colors to help operators identify active browser profile context quickly.", "hasChildren": false @@ -6867,7 +7371,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Browser Default Profile", "help": "Default browser profile name selected when callers do not explicitly choose a profile. Use a stable low-privilege profile as the default to reduce accidental cross-context state use.", "hasChildren": false @@ -6879,7 +7385,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser Enabled", "help": "Enables browser capability wiring in the gateway so browser tools and CDP-driven workflows can run. Disable when browser automation is not needed to reduce surface area and startup work.", "hasChildren": false @@ -6891,7 +7399,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser Evaluate Enabled", "help": "Enables browser-side evaluate helpers for runtime script evaluation capabilities where supported. Keep disabled unless your workflows require evaluate semantics beyond snapshots/navigation.", "hasChildren": false @@ -6903,7 +7413,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Browser Executable Path", "help": "Explicit browser executable path when auto-discovery is insufficient for your host environment. Use absolute stable paths so launch behavior stays deterministic across restarts.", "hasChildren": false @@ -6935,7 +7447,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser Headless Mode", "help": "Forces browser launch in headless mode when the local launcher starts browser instances. Keep headless enabled for server environments and disable only when visible UI debugging is required.", "hasChildren": false @@ -6947,7 +7461,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Browser No-Sandbox Mode", "help": "Disables Chromium sandbox isolation flags for environments where sandboxing fails at runtime. Keep this off whenever possible because process isolation protections are reduced.", "hasChildren": false @@ -6959,7 +7475,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Browser Profiles", "help": "Named browser profile connection map used for explicit routing to CDP ports or URLs with optional metadata. Keep profile names consistent and avoid overlapping endpoint definitions.", "hasChildren": true @@ -6981,7 +7499,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Browser Profile Attach-only Mode", "help": "Per-profile attach-only override that skips local browser launch and only attaches to an existing CDP endpoint. Useful when one profile is externally managed but others are locally launched.", "hasChildren": false @@ -6993,7 +7513,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Browser Profile CDP Port", "help": "Per-profile local CDP port used when connecting to browser instances by port instead of URL. Use unique ports per profile to avoid connection collisions.", "hasChildren": false @@ -7005,7 +7527,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Browser Profile CDP URL", "help": "Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.", "hasChildren": false @@ -7017,7 +7541,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Browser Profile Accent Color", "help": "Per-profile accent color for visual differentiation in dashboards and browser-related UI hints. Use distinct colors for high-signal operator recognition of active profiles.", "hasChildren": false @@ -7029,7 +7555,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Browser Profile Driver", "help": "Per-profile browser driver mode: \"openclaw\" (or legacy \"clawd\") or \"extension\" depending on connection/runtime strategy. Use the driver that matches your browser control stack to avoid protocol mismatches.", "hasChildren": false @@ -7041,7 +7569,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser Relay Bind Address", "help": "Bind IP address for the Chrome extension relay listener. Leave unset for loopback-only access, or set an explicit non-loopback IP such as 0.0.0.0 only when the relay must be reachable across network namespaces (for example WSL2) and the surrounding network is already trusted.", "hasChildren": false @@ -7053,7 +7583,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Remote CDP Handshake Timeout (ms)", "help": "Timeout in milliseconds for post-connect CDP handshake readiness checks against remote browser targets. Raise this for slow-start remote browsers and lower to fail fast in automation loops.", "hasChildren": false @@ -7065,7 +7597,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Remote CDP Timeout (ms)", "help": "Timeout in milliseconds for connecting to a remote CDP endpoint before failing the browser attach attempt. Increase for high-latency tunnels, or lower for faster failure detection.", "hasChildren": false @@ -7077,7 +7611,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser Snapshot Defaults", "help": "Default snapshot capture configuration used when callers do not provide explicit snapshot options. Tune this for consistent capture behavior across channels and automation paths.", "hasChildren": true @@ -7089,7 +7625,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser Snapshot Mode", "help": "Default snapshot extraction mode controlling how page content is transformed for agent consumption. Choose the mode that balances readability, fidelity, and token footprint for your workflows.", "hasChildren": false @@ -7101,7 +7639,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Browser SSRF Policy", "help": "Server-side request forgery guardrail settings for browser/network fetch paths that could reach internal hosts. Keep restrictive defaults in production and open only explicitly approved targets.", "hasChildren": true @@ -7113,7 +7653,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Browser Allowed Hostnames", "help": "Explicit hostname allowlist exceptions for SSRF policy checks on browser/network requests. Keep this list minimal and review entries regularly to avoid stale broad access.", "hasChildren": true @@ -7135,7 +7677,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Browser Allow Private Network", "help": "Legacy alias for browser.ssrfPolicy.dangerouslyAllowPrivateNetwork. Prefer the dangerously-named key so risk intent is explicit.", "hasChildren": false @@ -7147,7 +7691,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "advanced", "security"], + "tags": [ + "access", + "advanced", + "security" + ], "label": "Browser Dangerously Allow Private Network", "help": "Allows access to private-network address ranges from browser tooling. Default is enabled for trusted-network operator setups; disable to enforce strict public-only resolution checks.", "hasChildren": false @@ -7159,7 +7707,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Browser Hostname Allowlist", "help": "Legacy/alternate hostname allowlist field used by SSRF policy consumers for explicit host exceptions. Use stable exact hostnames and avoid wildcard-like broad patterns.", "hasChildren": true @@ -7181,7 +7731,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Canvas Host", "help": "Canvas host settings for serving canvas assets and local live-reload behavior used by canvas-enabled workflows. Keep disabled unless canvas-hosted assets are actively used.", "hasChildren": true @@ -7193,7 +7745,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Canvas Host Enabled", "help": "Enables the canvas host server process and routes for serving canvas files. Keep disabled when canvas workflows are inactive to reduce exposed local services.", "hasChildren": false @@ -7205,7 +7759,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["reliability"], + "tags": [ + "reliability" + ], "label": "Canvas Host Live Reload", "help": "Enables automatic live-reload behavior for canvas assets during development workflows. Keep disabled in production-like environments where deterministic output is preferred.", "hasChildren": false @@ -7217,7 +7773,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Canvas Host Port", "help": "TCP port used by the canvas host HTTP server when canvas hosting is enabled. Choose a non-conflicting port and align firewall/proxy policy accordingly.", "hasChildren": false @@ -7229,7 +7787,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Canvas Host Root Directory", "help": "Filesystem root directory served by canvas host for canvas content and static assets. Use a dedicated directory and avoid broad repo roots for least-privilege file exposure.", "hasChildren": false @@ -7241,7 +7801,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Channels", "help": "Channel provider configurations plus shared defaults that control access policies, heartbeat visibility, and per-surface behavior. Keep defaults centralized and override per provider only where required.", "hasChildren": true @@ -7253,7 +7815,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "BlueBubbles", "help": "iMessage via the BlueBubbles mac app + REST API.", "hasChildren": true @@ -7291,7 +7856,10 @@ { "path": "channels.bluebubbles.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -7323,7 +7891,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -7344,7 +7915,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -7373,7 +7949,10 @@ { "path": "channels.bluebubbles.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -7385,7 +7964,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -7516,7 +8099,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -7565,11 +8152,19 @@ { "path": "channels.bluebubbles.accounts.*.password", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -7786,7 +8381,10 @@ { "path": "channels.bluebubbles.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -7818,7 +8416,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -7849,10 +8450,19 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "BlueBubbles DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.bluebubbles.allowFrom=[\"*\"].", "hasChildren": false @@ -7880,7 +8490,10 @@ { "path": "channels.bluebubbles.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -7892,7 +8505,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -8023,7 +8640,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -8072,11 +8693,19 @@ { "path": "channels.bluebubbles.password", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -8156,7 +8785,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord", "help": "very well supported right now.", "hasChildren": true @@ -8196,7 +8828,14 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["group-mentions", "group-all", "direct", "all", "off", "none"], + "enumValues": [ + "group-mentions", + "group-all", + "direct", + "all", + "off", + "none" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -8455,7 +9094,10 @@ { "path": "channels.discord.accounts.*.allowBots", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -8475,7 +9117,10 @@ { "path": "channels.discord.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -8627,7 +9272,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -8646,7 +9294,10 @@ { "path": "channels.discord.accounts.*.commands.native", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -8656,7 +9307,10 @@ { "path": "channels.discord.accounts.*.commands.nativeSkills", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -8716,7 +9370,10 @@ { "path": "channels.discord.accounts.*.dm.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -8746,7 +9403,10 @@ { "path": "channels.discord.accounts.*.dm.groupChannels.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -8768,7 +9428,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -8789,7 +9454,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -8958,7 +9628,10 @@ { "path": "channels.discord.accounts.*.execApprovals.approvers.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -9010,7 +9683,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["dm", "channel", "both"], + "enumValues": [ + "dm", + "channel", + "both" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -9021,7 +9698,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -9081,9 +9762,17 @@ { "path": "channels.discord.accounts.*.guilds.*.channels.*.autoArchiveDuration", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, - "enumValues": ["60", "1440", "4320", "10080"], + "enumValues": [ + "60", + "1440", + "4320", + "10080" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -9152,7 +9841,10 @@ { "path": "channels.discord.accounts.*.guilds.*.channels.*.roles.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -9352,7 +10044,10 @@ { "path": "channels.discord.accounts.*.guilds.*.channels.*.users.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -9374,7 +10069,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "own", "all", "allowlist"], + "enumValues": [ + "off", + "own", + "all", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -9403,7 +10103,10 @@ { "path": "channels.discord.accounts.*.guilds.*.roles.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -9583,7 +10286,30 @@ { "path": "channels.discord.accounts.*.guilds.*.users.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.discord.accounts.*.healthMonitor", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.discord.accounts.*.healthMonitor.enabled", + "kind": "channel", + "type": "boolean", "required": false, "deprecated": false, "sensitive": false, @@ -9705,7 +10431,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -9764,11 +10494,19 @@ { "path": "channels.discord.accounts.*.pluralkit.token", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -9906,7 +10644,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["online", "dnd", "idle", "invisible"], + "enumValues": [ + "online", + "dnd", + "idle", + "invisible" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -9915,9 +10658,17 @@ { "path": "channels.discord.accounts.*.streaming", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, - "enumValues": ["off", "partial", "block", "progress"], + "enumValues": [ + "off", + "partial", + "block", + "progress" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -9928,7 +10679,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["partial", "block", "off"], + "enumValues": [ + "partial", + "block", + "off" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -10007,11 +10762,19 @@ { "path": "channels.discord.accounts.*.token", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -10169,7 +10932,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "always", "inbound", "tagged"], + "enumValues": [ + "off", + "always", + "inbound", + "tagged" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -10298,11 +11066,20 @@ { "path": "channels.discord.accounts.*.voice.tts.elevenlabs.apiKey", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "media", "network", "security"], + "tags": [ + "auth", + "channels", + "media", + "network", + "security" + ], "hasChildren": true }, { @@ -10340,7 +11117,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["auto", "on", "off"], + "enumValues": [ + "auto", + "on", + "off" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -10481,7 +11262,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["final", "all"], + "enumValues": [ + "final", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -10590,11 +11374,20 @@ { "path": "channels.discord.accounts.*.voice.tts.openai.apiKey", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "media", "network", "security"], + "tags": [ + "auth", + "channels", + "media", + "network", + "security" + ], "hasChildren": true }, { @@ -10692,7 +11485,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["elevenlabs", "openai", "edge"], + "enumValues": [ + "elevenlabs", + "openai", + "edge" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -10733,7 +11530,14 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["group-mentions", "group-all", "direct", "all", "off", "none"], + "enumValues": [ + "group-mentions", + "group-all", + "direct", + "all", + "off", + "none" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -10946,7 +11750,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Presence Activity", "help": "Discord presence activity text (defaults to custom status).", "hasChildren": false @@ -10958,7 +11765,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Presence Activity Type", "help": "Discord presence activity type (0=Playing,1=Streaming,2=Listening,3=Watching,4=Custom,5=Competing).", "hasChildren": false @@ -10970,7 +11780,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Presence Activity URL", "help": "Discord presence streaming URL (required for activityType=1).", "hasChildren": false @@ -10998,11 +11811,18 @@ { "path": "channels.discord.allowBots", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "Discord Allow Bot Messages", "help": "Allow bot-authored messages to trigger Discord replies (default: false). Set \"mentions\" to only accept bot messages that mention the bot.", "hasChildren": false @@ -11020,7 +11840,10 @@ { "path": "channels.discord.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -11044,7 +11867,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Auto Presence Degraded Text", "help": "Optional custom status text while runtime/model availability is degraded or unknown (idle).", "hasChildren": false @@ -11056,7 +11882,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Auto Presence Enabled", "help": "Enable automatic Discord bot presence updates based on runtime/model availability signals. When enabled: healthy=>online, degraded/unknown=>idle, exhausted/unavailable=>dnd.", "hasChildren": false @@ -11068,7 +11897,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Auto Presence Exhausted Text", "help": "Optional custom status text while runtime detects exhausted/unavailable model quota (dnd). Supports {reason} template placeholder.", "hasChildren": false @@ -11080,7 +11912,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "reliability"], + "tags": [ + "channels", + "network", + "reliability" + ], "label": "Discord Auto Presence Healthy Text", "help": "Optional custom status text while runtime is healthy (online). If omitted, falls back to static channels.discord.activity when set.", "hasChildren": false @@ -11092,7 +11928,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "Discord Auto Presence Check Interval (ms)", "help": "How often to evaluate Discord auto-presence state in milliseconds (default: 30000).", "hasChildren": false @@ -11104,7 +11944,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "Discord Auto Presence Min Update Interval (ms)", "help": "Minimum time between actual Discord presence update calls in milliseconds (default: 15000). Prevents status spam on noisy state changes.", "hasChildren": false @@ -11184,7 +12028,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -11203,11 +12050,17 @@ { "path": "channels.discord.commands.native", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Native Commands", "help": "Override native commands for Discord (bool or \"auto\").", "hasChildren": false @@ -11215,11 +12068,17 @@ { "path": "channels.discord.commands.nativeSkills", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Native Skill Commands", "help": "Override native skill commands for Discord (bool or \"auto\").", "hasChildren": false @@ -11231,7 +12090,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Config Writes", "help": "Allow Discord to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -11289,7 +12151,10 @@ { "path": "channels.discord.dm.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -11319,7 +12184,10 @@ { "path": "channels.discord.dm.groupChannels.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -11341,10 +12209,19 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "Discord DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.discord.allowFrom=[\"*\"] (legacy: channels.discord.dm.allowFrom).", "hasChildren": false @@ -11364,10 +12241,19 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "Discord DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.discord.allowFrom=[\"*\"].", "hasChildren": false @@ -11419,7 +12305,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Draft Chunk Break Preference", "help": "Preferred breakpoints for Discord draft chunks (paragraph | newline | sentence). Default: paragraph.", "hasChildren": false @@ -11431,7 +12320,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "Discord Draft Chunk Max Chars", "help": "Target max size for a Discord stream preview chunk when channels.discord.streaming=\"block\" (default: 800; clamped to channels.discord.textChunkLimit).", "hasChildren": false @@ -11443,7 +12336,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Draft Chunk Min Chars", "help": "Minimum chars before emitting a Discord stream preview update when channels.discord.streaming=\"block\" (default: 200).", "hasChildren": false @@ -11475,7 +12371,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "Discord EventQueue Listener Timeout (ms)", "help": "Canonical Discord listener timeout control in ms for gateway normalization/enqueue handlers. Default is 120000 in OpenClaw; set per account via channels.discord.accounts..eventQueue.listenerTimeout.", "hasChildren": false @@ -11487,7 +12387,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "Discord EventQueue Max Concurrency", "help": "Optional Discord EventQueue concurrency override (max concurrent handler executions). Set per account via channels.discord.accounts..eventQueue.maxConcurrency.", "hasChildren": false @@ -11499,7 +12403,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "Discord EventQueue Max Queue Size", "help": "Optional Discord EventQueue capacity override (max queued events before backpressure). Set per account via channels.discord.accounts..eventQueue.maxQueueSize.", "hasChildren": false @@ -11547,7 +12455,10 @@ { "path": "channels.discord.execApprovals.approvers.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -11599,7 +12510,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["dm", "channel", "both"], + "enumValues": [ + "dm", + "channel", + "both" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -11610,7 +12525,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -11670,9 +12589,17 @@ { "path": "channels.discord.guilds.*.channels.*.autoArchiveDuration", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, - "enumValues": ["60", "1440", "4320", "10080"], + "enumValues": [ + "60", + "1440", + "4320", + "10080" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -11741,7 +12668,10 @@ { "path": "channels.discord.guilds.*.channels.*.roles.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -11941,7 +12871,10 @@ { "path": "channels.discord.guilds.*.channels.*.users.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -11963,7 +12896,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "own", "all", "allowlist"], + "enumValues": [ + "off", + "own", + "all", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -11992,7 +12930,10 @@ { "path": "channels.discord.guilds.*.roles.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -12172,7 +13113,30 @@ { "path": "channels.discord.guilds.*.users.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.discord.healthMonitor", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.discord.healthMonitor.enabled", + "kind": "channel", + "type": "boolean", "required": false, "deprecated": false, "sensitive": false, @@ -12246,7 +13210,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "Discord Inbound Worker Timeout (ms)", "help": "Optional queued Discord inbound worker timeout in ms. This is separate from Carbon listener timeouts; defaults to 1800000 and can be disabled with 0. Set per account via channels.discord.accounts..inboundWorker.runTimeoutMs.", "hasChildren": false @@ -12268,7 +13236,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Guild Members Intent", "help": "Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.", "hasChildren": false @@ -12280,7 +13251,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Presence Intent", "help": "Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.", "hasChildren": false @@ -12300,7 +13274,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -12313,7 +13291,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "Discord Max Lines Per Message", "help": "Soft max line count per Discord message (default: 17).", "hasChildren": false @@ -12355,7 +13337,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord PluralKit Enabled", "help": "Resolve PluralKit proxied messages and treat system members as distinct senders.", "hasChildren": false @@ -12363,11 +13348,19 @@ { "path": "channels.discord.pluralkit.token", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "label": "Discord PluralKit Token", "help": "Optional PluralKit token for resolving private systems or members.", "hasChildren": true @@ -12409,7 +13402,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Proxy URL", "help": "Proxy URL for Discord gateway + API requests (app-id lookup and allowlist resolution). Set per account via channels.discord.accounts..proxy.", "hasChildren": false @@ -12451,7 +13447,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "reliability"], + "tags": [ + "channels", + "network", + "reliability" + ], "label": "Discord Retry Attempts", "help": "Max retry attempts for outbound Discord API calls (default: 3).", "hasChildren": false @@ -12463,7 +13463,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "reliability"], + "tags": [ + "channels", + "network", + "reliability" + ], "label": "Discord Retry Jitter", "help": "Jitter factor (0-1) applied to Discord retry delays.", "hasChildren": false @@ -12475,7 +13479,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance", "reliability"], + "tags": [ + "channels", + "network", + "performance", + "reliability" + ], "label": "Discord Retry Max Delay (ms)", "help": "Maximum retry delay cap in ms for Discord outbound calls.", "hasChildren": false @@ -12487,7 +13496,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "reliability"], + "tags": [ + "channels", + "network", + "reliability" + ], "label": "Discord Retry Min Delay (ms)", "help": "Minimum retry delay in ms for Discord outbound calls.", "hasChildren": false @@ -12517,10 +13530,18 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["online", "dnd", "idle", "invisible"], + "enumValues": [ + "online", + "dnd", + "idle", + "invisible" + ], "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Presence Status", "help": "Discord presence status (online, dnd, idle, invisible).", "hasChildren": false @@ -12528,12 +13549,23 @@ { "path": "channels.discord.streaming", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, - "enumValues": ["off", "partial", "block", "progress"], + "enumValues": [ + "off", + "partial", + "block", + "progress" + ], "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Streaming Mode", "help": "Unified Discord stream preview mode: \"off\" | \"partial\" | \"block\" | \"progress\". \"progress\" maps to \"partial\" on Discord. Legacy boolean/streamMode keys are auto-mapped.", "hasChildren": false @@ -12543,10 +13575,17 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["partial", "block", "off"], + "enumValues": [ + "partial", + "block", + "off" + ], "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Stream Mode (Legacy)", "help": "Legacy Discord preview mode alias (off | partial | block); auto-migrated to channels.discord.streaming.", "hasChildren": false @@ -12578,7 +13617,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "storage"], + "tags": [ + "channels", + "network", + "storage" + ], "label": "Discord Thread Binding Enabled", "help": "Enable Discord thread binding features (/focus, bound-thread routing/delivery, and thread-bound subagent sessions). Overrides session.threadBindings.enabled when set.", "hasChildren": false @@ -12590,7 +13633,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "storage"], + "tags": [ + "channels", + "network", + "storage" + ], "label": "Discord Thread Binding Idle Timeout (hours)", "help": "Inactivity window in hours for Discord thread-bound sessions (/focus and spawned thread sessions). Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set.", "hasChildren": false @@ -12602,7 +13649,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance", "storage"], + "tags": [ + "channels", + "network", + "performance", + "storage" + ], "label": "Discord Thread Binding Max Age (hours)", "help": "Optional hard max age in hours for Discord thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.", "hasChildren": false @@ -12614,7 +13666,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "storage"], + "tags": [ + "channels", + "network", + "storage" + ], "label": "Discord Thread-Bound ACP Spawn", "help": "Allow /acp spawn to auto-create and bind Discord threads for ACP sessions (default: false; opt-in). Set true to enable thread-bound ACP spawns for this account/channel.", "hasChildren": false @@ -12626,7 +13682,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "storage"], + "tags": [ + "channels", + "network", + "storage" + ], "label": "Discord Thread-Bound Subagent Spawn", "help": "Allow subagent spawns with thread=true to auto-create and bind Discord threads (default: false; opt-in). Set true to enable thread-bound subagent spawns for this account/channel.", "hasChildren": false @@ -12634,11 +13694,19 @@ { "path": "channels.discord.token", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "label": "Discord Bot Token", "help": "Discord bot token used for gateway and REST API authentication for this provider account. Keep this secret out of committed config and rotate immediately after any leak.", "hasChildren": true @@ -12700,7 +13768,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Component Accent Color", "help": "Accent color for Discord component containers (hex). Set per account via channels.discord.accounts..ui.components.accentColor.", "hasChildren": false @@ -12722,7 +13793,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Voice Auto-Join", "help": "Voice channels to auto-join on startup (list of guildId/channelId entries).", "hasChildren": true @@ -12764,7 +13838,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Voice DAVE Encryption", "help": "Toggle DAVE end-to-end encryption for Discord voice joins (default: true in @discordjs/voice; Discord may require this).", "hasChildren": false @@ -12776,7 +13853,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Voice Decrypt Failure Tolerance", "help": "Consecutive decrypt failures before DAVE attempts session recovery (passed to @discordjs/voice; default: 24).", "hasChildren": false @@ -12788,7 +13868,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Voice Enabled", "help": "Enable Discord voice channel conversations (default: true). Omit channels.discord.voice to keep voice support disabled for the account.", "hasChildren": false @@ -12800,7 +13883,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "media", "network"], + "tags": [ + "channels", + "media", + "network" + ], "label": "Discord Voice Text-to-Speech", "help": "Optional TTS overrides for Discord voice playback (merged with messages.tts).", "hasChildren": true @@ -12810,7 +13897,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "always", "inbound", "tagged"], + "enumValues": [ + "off", + "always", + "inbound", + "tagged" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -12939,11 +14031,20 @@ { "path": "channels.discord.voice.tts.elevenlabs.apiKey", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "media", "network", "security"], + "tags": [ + "auth", + "channels", + "media", + "network", + "security" + ], "hasChildren": true }, { @@ -12981,7 +14082,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["auto", "on", "off"], + "enumValues": [ + "auto", + "on", + "off" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -13122,7 +14227,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["final", "all"], + "enumValues": [ + "final", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -13231,11 +14339,20 @@ { "path": "channels.discord.voice.tts.openai.apiKey", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "media", "network", "security"], + "tags": [ + "auth", + "channels", + "media", + "network", + "security" + ], "hasChildren": true }, { @@ -13333,7 +14450,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["elevenlabs", "openai", "edge"], + "enumValues": [ + "elevenlabs", + "openai", + "edge" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -13366,7 +14487,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Feishu", "help": "飞书/Lark enterprise messaging.", "hasChildren": true @@ -13391,6 +14515,49 @@ "tags": [], "hasChildren": true }, + { + "path": "channels.feishu.accounts.*.actions", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.accounts.*.actions.reactions", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.allowFrom", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.accounts.*.allowFrom.*", + "kind": "channel", + "type": [ + "number", + "string" + ], + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.feishu.accounts.*.appId", "kind": "channel", @@ -13404,7 +14571,10 @@ { "path": "channels.feishu.accounts.*.appSecret", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -13436,7 +14606,90 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["env", "file", "exec"], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.blockStreamingCoalesce", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.accounts.*.blockStreamingCoalesce.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.blockStreamingCoalesce.maxDelayMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.blockStreamingCoalesce.minDelayMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.capabilities", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.accounts.*.capabilities.*", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.chunkMode", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "length", + "newline" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.configWrites", + "kind": "channel", + "type": "boolean", + "required": false, "deprecated": false, "sensitive": false, "tags": [], @@ -13447,7 +14700,75 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["websocket", "webhook"], + "enumValues": [ + "websocket", + "webhook" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.dmHistoryLimit", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.dmPolicy", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "open", + "pairing", + "allowlist" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.dms", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.accounts.*.dms.*", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.accounts.*.dms.*.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.dms.*.systemPrompt", + "kind": "channel", + "type": "string", + "required": false, "deprecated": false, "sensitive": false, "tags": [], @@ -13458,7 +14779,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["feishu", "lark"], + "enumValues": [ + "feishu", + "lark" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -13477,7 +14801,10 @@ { "path": "channels.feishu.accounts.*.encryptKey", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -13509,7 +14836,374 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["env", "file", "exec"], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.groupAllowFrom", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.accounts.*.groupAllowFrom.*", + "kind": "channel", + "type": [ + "number", + "string" + ], + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.groupPolicy", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "open", + "allowlist", + "disabled" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.groups", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.accounts.*.groups.*", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.accounts.*.groups.*.allowFrom", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.accounts.*.groups.*.allowFrom.*", + "kind": "channel", + "type": [ + "number", + "string" + ], + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.groups.*.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.groups.*.groupSessionScope", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "group", + "group_sender", + "group_topic", + "group_topic_sender" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.groups.*.replyInThread", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "disabled", + "enabled" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.groups.*.requireMention", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.groups.*.skills", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.accounts.*.groups.*.skills.*", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.groups.*.systemPrompt", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.groups.*.tools", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.accounts.*.groups.*.tools.allow", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.accounts.*.groups.*.tools.allow.*", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.groups.*.tools.deny", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.accounts.*.groups.*.tools.deny.*", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.groups.*.topicSessionMode", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "disabled", + "enabled" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.groupSenderAllowFrom", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.accounts.*.groupSenderAllowFrom.*", + "kind": "channel", + "type": [ + "number", + "string" + ], + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.groupSessionScope", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "group", + "group_sender", + "group_topic", + "group_topic_sender" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.heartbeat", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.accounts.*.heartbeat.intervalMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.heartbeat.visibility", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "visible", + "hidden" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.historyLimit", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.httpTimeoutMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.markdown", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.accounts.*.markdown.mode", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "native", + "escape", + "strip" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.markdown.tableMode", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "native", + "ascii", + "simple" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.mediaMaxMb", + "kind": "channel", + "type": "number", + "required": false, "deprecated": false, "sensitive": false, "tags": [], @@ -13525,10 +15219,191 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.feishu.accounts.*.reactionNotifications", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "off", + "own", + "all" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.renderMode", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "auto", + "raw", + "card" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.replyInThread", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "disabled", + "enabled" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.requireMention", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.resolveSenderNames", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.streaming", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.textChunkLimit", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.tools", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.accounts.*.tools.chat", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.tools.doc", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.tools.drive", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.tools.perm", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.tools.scopes", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.tools.wiki", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.topicSessionMode", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "disabled", + "enabled" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.accounts.*.typingIndicator", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.feishu.accounts.*.verificationToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -13560,7 +15435,6 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["env", "file", "exec"], "deprecated": false, "sensitive": false, "tags": [], @@ -13596,6 +15470,26 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.feishu.actions", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.actions.reactions", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.feishu.allowFrom", "kind": "channel", @@ -13609,7 +15503,10 @@ { "path": "channels.feishu.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -13629,7 +15526,10 @@ { "path": "channels.feishu.appSecret", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -13661,7 +15561,66 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["env", "file", "exec"], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.blockStreamingCoalesce", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.blockStreamingCoalesce.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.blockStreamingCoalesce.maxDelayMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.blockStreamingCoalesce.minDelayMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.capabilities", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.capabilities.*", + "kind": "channel", + "type": "string", + "required": false, "deprecated": false, "sensitive": false, "tags": [], @@ -13672,7 +15631,20 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.configWrites", + "kind": "channel", + "type": "boolean", + "required": false, "deprecated": false, "sensitive": false, "tags": [], @@ -13682,8 +15654,12 @@ "path": "channels.feishu.connectionMode", "kind": "channel", "type": "string", - "required": false, - "enumValues": ["websocket", "webhook"], + "required": true, + "enumValues": [ + "websocket", + "webhook" + ], + "defaultValue": "websocket", "deprecated": false, "sensitive": false, "tags": [], @@ -13713,8 +15689,53 @@ "path": "channels.feishu.dmPolicy", "kind": "channel", "type": "string", + "required": true, + "enumValues": [ + "open", + "pairing", + "allowlist" + ], + "defaultValue": "pairing", + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.dms", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.dms.*", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.dms.*.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.dms.*.systemPrompt", + "kind": "channel", + "type": "string", "required": false, - "enumValues": ["open", "pairing", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -13724,8 +15745,61 @@ "path": "channels.feishu.domain", "kind": "channel", "type": "string", + "required": true, + "enumValues": [ + "feishu", + "lark" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.dynamicAgentCreation", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.dynamicAgentCreation.agentDirTemplate", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.dynamicAgentCreation.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.dynamicAgentCreation.maxAgents", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.dynamicAgentCreation.workspaceTemplate", + "kind": "channel", + "type": "string", "required": false, - "enumValues": ["feishu", "lark"], "deprecated": false, "sensitive": false, "tags": [], @@ -13744,7 +15818,10 @@ { "path": "channels.feishu.encryptKey", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -13776,7 +15853,6 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["env", "file", "exec"], "deprecated": false, "sensitive": false, "tags": [], @@ -13795,7 +15871,10 @@ { "path": "channels.feishu.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -13806,8 +15885,222 @@ "path": "channels.feishu.groupPolicy", "kind": "channel", "type": "string", + "required": true, + "enumValues": [ + "open", + "allowlist", + "disabled" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.groups", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.groups.*", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.groups.*.allowFrom", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.groups.*.allowFrom.*", + "kind": "channel", + "type": [ + "number", + "string" + ], + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.groups.*.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.groups.*.groupSessionScope", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "group", + "group_sender", + "group_topic", + "group_topic_sender" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.groups.*.replyInThread", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "disabled", + "enabled" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.groups.*.requireMention", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.groups.*.skills", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.groups.*.skills.*", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.groups.*.systemPrompt", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.groups.*.tools", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.groups.*.tools.allow", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.groups.*.tools.allow.*", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.groups.*.tools.deny", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.groups.*.tools.deny.*", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.groups.*.topicSessionMode", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "disabled", + "enabled" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.groupSenderAllowFrom", + "kind": "channel", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.groupSenderAllowFrom.*", + "kind": "channel", + "type": [ + "number", + "string" + ], "required": false, - "enumValues": ["open", "allowlist", "disabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -13818,7 +16111,46 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["group", "group_sender", "group_topic", "group_topic_sender"], + "enumValues": [ + "group", + "group_sender", + "group_topic", + "group_topic_sender" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.heartbeat", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.heartbeat.intervalMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.heartbeat.visibility", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "visible", + "hidden" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -13834,6 +16166,56 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.feishu.httpTimeoutMs", + "kind": "channel", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.markdown", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.markdown.mode", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "native", + "escape", + "strip" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.markdown.tableMode", + "kind": "channel", + "type": "string", + "required": false, + "enumValues": [ + "native", + "ascii", + "simple" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.feishu.mediaMaxMb", "kind": "channel", @@ -13844,12 +16226,32 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.feishu.reactionNotifications", + "kind": "channel", + "type": "string", + "required": true, + "enumValues": [ + "off", + "own", + "all" + ], + "defaultValue": "own", + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.feishu.renderMode", "kind": "channel", "type": "string", "required": false, - "enumValues": ["auto", "raw", "card"], + "enumValues": [ + "auto", + "raw", + "card" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -13860,7 +16262,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["disabled", "enabled"], + "enumValues": [ + "disabled", + "enabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -13870,6 +16275,28 @@ "path": "channels.feishu.requireMention", "kind": "channel", "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.resolveSenderNames", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.streaming", + "kind": "channel", + "type": "boolean", "required": false, "deprecated": false, "sensitive": false, @@ -13886,12 +16313,96 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.feishu.tools", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.feishu.tools.chat", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.tools.doc", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.tools.drive", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.tools.perm", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.tools.scopes", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.tools.wiki", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.feishu.topicSessionMode", "kind": "channel", "type": "string", "required": false, - "enumValues": ["disabled", "enabled"], + "enumValues": [ + "disabled", + "enabled" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.feishu.typingIndicator", + "kind": "channel", + "type": "boolean", + "required": true, + "defaultValue": true, "deprecated": false, "sensitive": false, "tags": [], @@ -13900,7 +16411,10 @@ { "path": "channels.feishu.verificationToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -13932,7 +16446,6 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["env", "file", "exec"], "deprecated": false, "sensitive": false, "tags": [], @@ -13952,7 +16465,8 @@ "path": "channels.feishu.webhookPath", "kind": "channel", "type": "string", - "required": false, + "required": true, + "defaultValue": "/feishu/events", "deprecated": false, "sensitive": false, "tags": [], @@ -13975,7 +16489,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Google Chat", "help": "Google Workspace Chat app with HTTP webhook.", "hasChildren": true @@ -14030,6 +16547,16 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.googlechat.accounts.*.appPrincipal", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.googlechat.accounts.*.audience", "kind": "channel", @@ -14045,7 +16572,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["app-url", "project-number"], + "enumValues": [ + "app-url", + "project-number" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14136,7 +16666,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14195,7 +16728,10 @@ { "path": "channels.googlechat.accounts.*.dm.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -14217,7 +16753,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -14287,7 +16828,10 @@ { "path": "channels.googlechat.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -14299,7 +16843,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -14379,7 +16927,30 @@ { "path": "channels.googlechat.accounts.*.groups.*.users.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.googlechat.accounts.*.healthMonitor", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.googlechat.accounts.*.healthMonitor.enabled", + "kind": "channel", + "type": "boolean", "required": false, "deprecated": false, "sensitive": false, @@ -14449,11 +17020,18 @@ { "path": "channels.googlechat.accounts.*.serviceAccount", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["channels", "network", "security"], + "tags": [ + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -14512,7 +17090,11 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["channels", "network", "security"], + "tags": [ + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -14550,7 +17132,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["replace", "status_final", "append"], + "enumValues": [ + "replace", + "status_final", + "append" + ], "defaultValue": "replace", "deprecated": false, "sensitive": false, @@ -14572,7 +17158,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["none", "message", "reaction"], + "enumValues": [ + "none", + "message", + "reaction" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14628,6 +17218,16 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.googlechat.appPrincipal", + "kind": "channel", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.googlechat.audience", "kind": "channel", @@ -14643,7 +17243,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["app-url", "project-number"], + "enumValues": [ + "app-url", + "project-number" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14734,7 +17337,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14803,7 +17409,10 @@ { "path": "channels.googlechat.dm.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -14825,7 +17434,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -14895,7 +17509,10 @@ { "path": "channels.googlechat.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -14907,7 +17524,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -14987,7 +17608,30 @@ { "path": "channels.googlechat.groups.*.users.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.googlechat.healthMonitor", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.googlechat.healthMonitor.enabled", + "kind": "channel", + "type": "boolean", "required": false, "deprecated": false, "sensitive": false, @@ -15057,11 +17701,18 @@ { "path": "channels.googlechat.serviceAccount", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["channels", "network", "security"], + "tags": [ + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -15120,7 +17771,11 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["channels", "network", "security"], + "tags": [ + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -15158,7 +17813,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["replace", "status_final", "append"], + "enumValues": [ + "replace", + "status_final", + "append" + ], "defaultValue": "replace", "deprecated": false, "sensitive": false, @@ -15180,7 +17839,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["none", "message", "reaction"], + "enumValues": [ + "none", + "message", + "reaction" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -15213,7 +17876,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "iMessage", "help": "this is still a work in progress.", "hasChildren": true @@ -15251,7 +17917,10 @@ { "path": "channels.imessage.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -15353,7 +18022,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -15414,7 +18086,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -15474,7 +18151,10 @@ { "path": "channels.imessage.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -15486,7 +18166,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -15673,6 +18357,26 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.imessage.accounts.*.healthMonitor", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.imessage.accounts.*.healthMonitor.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.imessage.accounts.*.heartbeat", "kind": "channel", @@ -15748,7 +18452,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -15857,7 +18565,10 @@ { "path": "channels.imessage.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -15959,7 +18670,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -15972,7 +18686,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "storage"], + "tags": [ + "channels", + "network", + "storage" + ], "label": "iMessage CLI Path", "help": "Filesystem path to the iMessage bridge CLI binary used for send/receive operations. Set explicitly when the binary is not on PATH in service runtime environments.", "hasChildren": false @@ -15984,7 +18702,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "iMessage Config Writes", "help": "Allow iMessage to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -16034,11 +18755,20 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "iMessage DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.imessage.allowFrom=[\"*\"].", "hasChildren": false @@ -16096,7 +18826,10 @@ { "path": "channels.imessage.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -16108,7 +18841,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -16295,6 +19032,26 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.imessage.healthMonitor", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.imessage.healthMonitor.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.imessage.heartbeat", "kind": "channel", @@ -16370,7 +19127,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -16473,7 +19234,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "IRC", "help": "classic IRC networks with DM/channel routing and pairing controls.", "hasChildren": true @@ -16511,7 +19275,10 @@ { "path": "channels.irc.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -16593,7 +19360,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -16624,7 +19394,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -16684,7 +19459,10 @@ { "path": "channels.irc.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -16696,7 +19474,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -16736,7 +19518,10 @@ { "path": "channels.irc.accounts.*.groups.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -16978,7 +19763,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -17061,7 +19850,12 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": false }, { @@ -17111,7 +19905,12 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": false }, { @@ -17197,7 +19996,10 @@ { "path": "channels.irc.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -17279,7 +20081,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -17320,11 +20125,20 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "IRC DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.irc.allowFrom=[\"*\"].", "hasChildren": false @@ -17382,7 +20196,10 @@ { "path": "channels.irc.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -17394,7 +20211,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -17434,7 +20255,10 @@ { "path": "channels.irc.groups.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -17676,7 +20500,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -17749,7 +20577,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "IRC NickServ Enabled", "help": "Enable NickServ identify/register after connect (defaults to enabled when password is configured).", "hasChildren": false @@ -17761,7 +20592,12 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "label": "IRC NickServ Password", "help": "NickServ password used for IDENTIFY/REGISTER (sensitive).", "hasChildren": false @@ -17773,7 +20609,13 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["auth", "channels", "network", "security", "storage"], + "tags": [ + "auth", + "channels", + "network", + "security", + "storage" + ], "label": "IRC NickServ Password File", "help": "Optional file path containing NickServ password.", "hasChildren": false @@ -17785,7 +20627,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "IRC NickServ Register", "help": "If true, send NickServ REGISTER on every connect. Use once for initial registration, then disable.", "hasChildren": false @@ -17797,7 +20642,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "IRC NickServ Register Email", "help": "Email used with NickServ REGISTER (required when register=true).", "hasChildren": false @@ -17809,7 +20657,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "IRC NickServ Service", "help": "NickServ service nick (default: NickServ).", "hasChildren": false @@ -17821,7 +20672,12 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": false }, { @@ -17901,7 +20757,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "LINE", "help": "LINE Messaging API bot for Japan/Taiwan/Thailand markets.", "hasChildren": true @@ -17939,7 +20798,10 @@ { "path": "channels.line.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -17971,7 +20833,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "allowlist", "pairing", "disabled"], + "enumValues": [ + "open", + "allowlist", + "pairing", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -18001,7 +20868,10 @@ { "path": "channels.line.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -18013,7 +20883,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "allowlist", "disabled"], + "enumValues": [ + "open", + "allowlist", + "disabled" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -18053,7 +20927,10 @@ { "path": "channels.line.accounts.*.groups.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -18183,7 +21060,10 @@ { "path": "channels.line.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -18225,7 +21105,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "allowlist", "pairing", "disabled"], + "enumValues": [ + "open", + "allowlist", + "pairing", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -18255,7 +21140,10 @@ { "path": "channels.line.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -18267,7 +21155,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "allowlist", "disabled"], + "enumValues": [ + "open", + "allowlist", + "disabled" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -18307,7 +21199,10 @@ { "path": "channels.line.groups.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -18431,7 +21326,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Matrix", "help": "open protocol; configure a homeserver + access token.", "hasChildren": true @@ -18540,7 +21438,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["always", "allowlist", "off"], + "enumValues": [ + "always", + "allowlist", + "off" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -18559,7 +21461,10 @@ { "path": "channels.matrix.autoJoinAllowlist.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -18571,7 +21476,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -18620,7 +21528,10 @@ { "path": "channels.matrix.dm.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -18642,7 +21553,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -18681,7 +21597,10 @@ { "path": "channels.matrix.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -18693,7 +21612,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -18872,7 +21795,10 @@ { "path": "channels.matrix.groups.*.users.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -18914,7 +21840,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -18943,7 +21873,10 @@ { "path": "channels.matrix.password", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -18985,7 +21918,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "first", "all"], + "enumValues": [ + "off", + "first", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -19174,7 +22111,10 @@ { "path": "channels.matrix.rooms.*.users.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -19196,7 +22136,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "inbound", "always"], + "enumValues": [ + "off", + "inbound", + "always" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -19219,7 +22163,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Mattermost", "help": "self-hosted Slack-style chat; install the plugin to enable.", "hasChildren": true @@ -19277,7 +22224,10 @@ { "path": "channels.mattermost.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -19347,7 +22297,10 @@ { "path": "channels.mattermost.accounts.*.botToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -19409,7 +22362,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["oncall", "onmessage", "onchar"], + "enumValues": [ + "oncall", + "onmessage", + "onchar" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -19420,7 +22377,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -19459,7 +22419,10 @@ { "path": "channels.mattermost.accounts.*.commands.native", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -19469,7 +22432,10 @@ { "path": "channels.mattermost.accounts.*.commands.nativeSkills", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -19501,7 +22467,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -19531,7 +22502,10 @@ { "path": "channels.mattermost.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -19543,7 +22517,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -19605,7 +22583,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -19646,7 +22628,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "first", "all"], + "enumValues": [ + "off", + "first", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -19715,7 +22701,10 @@ { "path": "channels.mattermost.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -19729,7 +22718,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Mattermost Base URL", "help": "Base URL for your Mattermost server (e.g., https://chat.example.com).", "hasChildren": false @@ -19787,11 +22779,19 @@ { "path": "channels.mattermost.botToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "label": "Mattermost Bot Token", "help": "Bot token from Mattermost System Console -> Integrations -> Bot Accounts.", "hasChildren": true @@ -19851,10 +22851,17 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["oncall", "onmessage", "onchar"], + "enumValues": [ + "oncall", + "onmessage", + "onchar" + ], "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Mattermost Chat Mode", "help": "Reply to channel messages on mention (\"oncall\"), on trigger chars (\">\" or \"!\") (\"onchar\"), or on every message (\"onmessage\").", "hasChildren": false @@ -19864,7 +22871,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -19903,7 +22913,10 @@ { "path": "channels.mattermost.commands.native", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -19913,7 +22926,10 @@ { "path": "channels.mattermost.commands.nativeSkills", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -19927,7 +22943,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Mattermost Config Writes", "help": "Allow Mattermost to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -19957,7 +22976,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -19987,7 +23011,10 @@ { "path": "channels.mattermost.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -19999,7 +23026,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -20061,7 +23092,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -20084,7 +23119,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Mattermost Onchar Prefixes", "help": "Trigger prefixes for onchar mode (default: [\">\", \"!\"]).", "hasChildren": true @@ -20104,7 +23142,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "first", "all"], + "enumValues": [ + "off", + "first", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -20117,7 +23159,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Mattermost Require Mention", "help": "Require @mention in channels before responding (default: true).", "hasChildren": false @@ -20149,7 +23194,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Microsoft Teams", "help": "Bot Framework; enterprise support.", "hasChildren": true @@ -20187,11 +23235,19 @@ { "path": "channels.msteams.appPassword", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -20289,7 +23345,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -20302,7 +23361,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "MS Teams Config Writes", "help": "Allow Microsoft Teams to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -20342,7 +23404,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -20414,13 +23481,37 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, "tags": [], "hasChildren": false }, + { + "path": "channels.msteams.healthMonitor", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.msteams.healthMonitor.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.msteams.heartbeat", "kind": "channel", @@ -20486,7 +23577,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -20547,7 +23642,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["thread", "top-level"], + "enumValues": [ + "thread", + "top-level" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -20628,7 +23726,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["thread", "top-level"], + "enumValues": [ + "thread", + "top-level" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -20799,7 +23900,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["thread", "top-level"], + "enumValues": [ + "thread", + "top-level" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -21022,7 +24126,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Nextcloud Talk", "help": "Self-hosted chat via Nextcloud Talk webhook bots.", "hasChildren": true @@ -21070,7 +24177,10 @@ { "path": "channels.nextcloud-talk.accounts.*.apiPassword", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -21190,7 +24300,10 @@ { "path": "channels.nextcloud-talk.accounts.*.botSecret", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -21242,7 +24355,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -21263,7 +24379,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -21335,7 +24456,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -21367,7 +24492,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -21636,7 +24765,10 @@ { "path": "channels.nextcloud-talk.apiPassword", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -21756,7 +24888,10 @@ { "path": "channels.nextcloud-talk.botSecret", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -21808,7 +24943,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -21839,7 +24977,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -21911,7 +25054,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -21943,7 +25090,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -22196,7 +25347,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Nostr", "help": "Decentralized DMs via Nostr relays (NIP-04)", "hasChildren": true @@ -22214,7 +25368,10 @@ { "path": "channels.nostr.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -22236,7 +25393,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -22267,7 +25429,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -22410,7 +25576,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Signal", "help": "signal-cli linked device; more setup (David Reagans: \"Hop on Discord.\").", "hasChildren": true @@ -22422,7 +25591,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Signal Account", "help": "Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state.", "hasChildren": false @@ -22500,7 +25672,10 @@ { "path": "channels.signal.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -22592,7 +25767,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -22643,7 +25821,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -22703,7 +25886,10 @@ { "path": "channels.signal.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -22715,7 +25901,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -22902,6 +26092,26 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.signal.accounts.*.healthMonitor", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.signal.accounts.*.healthMonitor.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.signal.accounts.*.heartbeat", "kind": "channel", @@ -23017,7 +26227,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -23056,7 +26270,10 @@ { "path": "channels.signal.accounts.*.reactionAllowlist.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -23068,7 +26285,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "ack", "minimal", "extensive"], + "enumValues": [ + "off", + "ack", + "minimal", + "extensive" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -23079,7 +26301,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "own", "all", "allowlist"], + "enumValues": [ + "off", + "own", + "all", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -23178,7 +26405,10 @@ { "path": "channels.signal.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -23270,7 +26500,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -23293,7 +26526,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Signal Config Writes", "help": "Allow Signal to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -23333,11 +26569,20 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "Signal DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.signal.allowFrom=[\"*\"].", "hasChildren": false @@ -23395,7 +26640,10 @@ { "path": "channels.signal.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -23407,7 +26655,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -23594,6 +26846,26 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.signal.healthMonitor", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.signal.healthMonitor.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.signal.heartbeat", "kind": "channel", @@ -23709,7 +26981,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -23748,7 +27024,10 @@ { "path": "channels.signal.reactionAllowlist.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -23760,7 +27039,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "ack", "minimal", "extensive"], + "enumValues": [ + "off", + "ack", + "minimal", + "extensive" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -23771,7 +27055,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "own", "all", "allowlist"], + "enumValues": [ + "off", + "own", + "all", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -23834,7 +27123,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Slack", "help": "supported (Socket Mode).", "hasChildren": true @@ -23982,7 +27274,10 @@ { "path": "channels.slack.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -23992,11 +27287,19 @@ { "path": "channels.slack.accounts.*.appToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -24082,11 +27385,19 @@ { "path": "channels.slack.accounts.*.botToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -24122,7 +27433,10 @@ { "path": "channels.slack.accounts.*.capabilities", "kind": "channel", - "type": ["array", "object"], + "type": [ + "array", + "object" + ], "required": false, "deprecated": false, "sensitive": false, @@ -24402,7 +27716,10 @@ { "path": "channels.slack.accounts.*.channels.*.users.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -24414,7 +27731,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -24433,7 +27753,10 @@ { "path": "channels.slack.accounts.*.commands.native", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -24443,7 +27766,10 @@ { "path": "channels.slack.accounts.*.commands.nativeSkills", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -24503,7 +27829,10 @@ { "path": "channels.slack.accounts.*.dm.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -24533,7 +27862,10 @@ { "path": "channels.slack.accounts.*.dm.groupChannels.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -24555,7 +27887,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -24586,7 +27923,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -24637,7 +27979,31 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "channels.slack.accounts.*.healthMonitor", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.slack.accounts.*.healthMonitor.enabled", + "kind": "channel", + "type": "boolean", + "required": false, "deprecated": false, "sensitive": false, "tags": [], @@ -24708,7 +28074,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -24729,7 +28099,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["socket", "http"], + "enumValues": [ + "socket", + "http" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -24768,7 +28141,10 @@ { "path": "channels.slack.accounts.*.reactionAllowlist.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -24780,7 +28156,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "own", "all", "allowlist"], + "enumValues": [ + "off", + "own", + "all", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -24859,11 +28240,19 @@ { "path": "channels.slack.accounts.*.signingSecret", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -24949,9 +28338,17 @@ { "path": "channels.slack.accounts.*.streaming", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, - "enumValues": ["off", "partial", "block", "progress"], + "enumValues": [ + "off", + "partial", + "block", + "progress" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -24962,7 +28359,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["replace", "status_final", "append"], + "enumValues": [ + "replace", + "status_final", + "append" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -24993,7 +28394,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["thread", "channel"], + "enumValues": [ + "thread", + "channel" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -25032,11 +28436,19 @@ { "path": "channels.slack.accounts.*.userToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -25197,7 +28609,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "Slack Allow Bot Messages", "help": "Allow bot-authored messages to trigger Slack replies (default: false).", "hasChildren": false @@ -25215,7 +28631,10 @@ { "path": "channels.slack.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -25225,11 +28644,19 @@ { "path": "channels.slack.appToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "label": "Slack App Token", "help": "Slack app-level token used for Socket Mode connections and event transport when enabled. Use least-privilege app scopes and store this token as a secret.", "hasChildren": true @@ -25317,11 +28744,19 @@ { "path": "channels.slack.botToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "label": "Slack Bot Token", "help": "Slack bot token used for standard chat actions in the configured workspace. Keep this credential scoped and rotate if workspace app permissions change.", "hasChildren": true @@ -25359,7 +28794,10 @@ { "path": "channels.slack.capabilities", "kind": "channel", - "type": ["array", "object"], + "type": [ + "array", + "object" + ], "required": false, "deprecated": false, "sensitive": false, @@ -25383,7 +28821,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Slack Interactive Replies", "help": "Enable agent-authored Slack interactive reply directives (`[[slack_buttons: ...]]`, `[[slack_select: ...]]`). Default: false.", "hasChildren": false @@ -25641,7 +29082,10 @@ { "path": "channels.slack.channels.*.users.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -25653,7 +29097,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -25672,11 +29119,17 @@ { "path": "channels.slack.commands.native", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Slack Native Commands", "help": "Override native commands for Slack (bool or \"auto\").", "hasChildren": false @@ -25684,11 +29137,17 @@ { "path": "channels.slack.commands.nativeSkills", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Slack Native Skill Commands", "help": "Override native skill commands for Slack (bool or \"auto\").", "hasChildren": false @@ -25700,7 +29159,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Slack Config Writes", "help": "Allow Slack to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -25758,7 +29220,10 @@ { "path": "channels.slack.dm.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -25788,7 +29253,10 @@ { "path": "channels.slack.dm.groupChannels.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -25810,10 +29278,19 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "Slack DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.slack.allowFrom=[\"*\"] (legacy: channels.slack.dm.allowFrom).", "hasChildren": false @@ -25843,10 +29320,19 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "Slack DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.slack.allowFrom=[\"*\"].", "hasChildren": false @@ -25896,13 +29382,37 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, "tags": [], "hasChildren": false }, + { + "path": "channels.slack.healthMonitor", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.slack.healthMonitor.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.slack.heartbeat", "kind": "channel", @@ -25968,7 +29478,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -25989,7 +29503,10 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["socket", "http"], + "enumValues": [ + "socket", + "http" + ], "defaultValue": "socket", "deprecated": false, "sensitive": false, @@ -26013,7 +29530,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Slack Native Streaming", "help": "Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming is partial (default: true).", "hasChildren": false @@ -26031,7 +29551,10 @@ { "path": "channels.slack.reactionAllowlist.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -26043,7 +29566,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "own", "all", "allowlist"], + "enumValues": [ + "off", + "own", + "all", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -26122,11 +29650,19 @@ { "path": "channels.slack.signingSecret", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -26212,12 +29748,23 @@ { "path": "channels.slack.streaming", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, - "enumValues": ["off", "partial", "block", "progress"], + "enumValues": [ + "off", + "partial", + "block", + "progress" + ], "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Slack Streaming Mode", "help": "Unified Slack stream preview mode: \"off\" | \"partial\" | \"block\" | \"progress\". Legacy boolean/streamMode keys are auto-mapped.", "hasChildren": false @@ -26227,10 +29774,17 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["replace", "status_final", "append"], + "enumValues": [ + "replace", + "status_final", + "append" + ], "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Slack Stream Mode (Legacy)", "help": "Legacy Slack preview mode alias (replace | status_final | append); auto-migrated to channels.slack.streaming.", "hasChildren": false @@ -26260,10 +29814,16 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["thread", "channel"], + "enumValues": [ + "thread", + "channel" + ], "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Slack Thread History Scope", "help": "Scope for Slack thread history context (\"thread\" isolates per thread; \"channel\" reuses channel history).", "hasChildren": false @@ -26275,7 +29835,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Slack Thread Parent Inheritance", "help": "If true, Slack thread sessions inherit the parent channel transcript (default: false).", "hasChildren": false @@ -26287,7 +29850,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "Slack Thread Initial History Limit", "help": "Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable).", "hasChildren": false @@ -26305,11 +29872,19 @@ { "path": "channels.slack.userToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "label": "Slack User Token", "help": "Optional Slack user token for workflows requiring user-context API access beyond bot permissions. Use sparingly and audit scopes because this token can carry broader authority.", "hasChildren": true @@ -26352,7 +29927,12 @@ "defaultValue": true, "deprecated": false, "sensitive": false, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "label": "Slack User Token Read Only", "help": "When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes.", "hasChildren": false @@ -26375,7 +29955,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Synology Chat", "help": "Connect your Synology NAS Chat to OpenClaw", "hasChildren": true @@ -26396,7 +29979,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram", "help": "simplest way to get started — register a bot with @BotFather and get going.", "hasChildren": true @@ -26524,7 +30110,10 @@ { "path": "channels.telegram.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -26584,11 +30173,19 @@ { "path": "channels.telegram.accounts.*.botToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -26624,7 +30221,10 @@ { "path": "channels.telegram.accounts.*.capabilities", "kind": "channel", - "type": ["array", "object"], + "type": [ + "array", + "object" + ], "required": false, "deprecated": false, "sensitive": false, @@ -26646,7 +30246,13 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "dm", "group", "all", "allowlist"], + "enumValues": [ + "off", + "dm", + "group", + "all", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -26657,7 +30263,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -26676,7 +30285,10 @@ { "path": "channels.telegram.accounts.*.commands.native", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -26686,7 +30298,10 @@ { "path": "channels.telegram.accounts.*.commands.nativeSkills", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -26746,7 +30361,10 @@ { "path": "channels.telegram.accounts.*.defaultTo", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -26786,7 +30404,10 @@ { "path": "channels.telegram.accounts.*.direct.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -26798,7 +30419,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -27047,7 +30673,10 @@ { "path": "channels.telegram.accounts.*.direct.*.topics.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -27079,7 +30708,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -27140,7 +30773,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -27270,7 +30908,10 @@ { "path": "channels.telegram.accounts.*.execApprovals.approvers.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -27312,7 +30953,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["dm", "channel", "both"], + "enumValues": [ + "dm", + "channel", + "both" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -27331,7 +30976,10 @@ { "path": "channels.telegram.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -27343,7 +30991,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -27383,7 +31035,10 @@ { "path": "channels.telegram.accounts.*.groups.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -27415,7 +31070,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -27654,7 +31313,10 @@ { "path": "channels.telegram.accounts.*.groups.*.topics.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -27686,7 +31348,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -27732,6 +31398,26 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.telegram.accounts.*.healthMonitor", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.telegram.accounts.*.healthMonitor.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.telegram.accounts.*.heartbeat", "kind": "channel", @@ -27807,7 +31493,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -27858,7 +31548,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["ipv4first", "verbatim"], + "enumValues": [ + "ipv4first", + "verbatim" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -27879,7 +31572,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "ack", "minimal", "extensive"], + "enumValues": [ + "off", + "ack", + "minimal", + "extensive" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -27890,7 +31588,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "own", "all"], + "enumValues": [ + "off", + "own", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -27969,9 +31671,17 @@ { "path": "channels.telegram.accounts.*.streaming", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, - "enumValues": ["off", "partial", "block", "progress"], + "enumValues": [ + "off", + "partial", + "block", + "progress" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -27982,7 +31692,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "partial", "block"], + "enumValues": [ + "off", + "partial", + "block" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -28121,11 +31835,19 @@ { "path": "channels.telegram.accounts.*.webhookSecret", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -28271,7 +31993,10 @@ { "path": "channels.telegram.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -28331,11 +32056,19 @@ { "path": "channels.telegram.botToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "label": "Telegram Bot Token", "help": "Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected.", "hasChildren": true @@ -28373,7 +32106,10 @@ { "path": "channels.telegram.capabilities", "kind": "channel", - "type": ["array", "object"], + "type": [ + "array", + "object" + ], "required": false, "deprecated": false, "sensitive": false, @@ -28395,10 +32131,19 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "dm", "group", "all", "allowlist"], + "enumValues": [ + "off", + "dm", + "group", + "all", + "allowlist" + ], "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Inline Buttons", "help": "Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior.", "hasChildren": false @@ -28408,7 +32153,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -28427,11 +32175,17 @@ { "path": "channels.telegram.commands.native", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Native Commands", "help": "Override native commands for Telegram (bool or \"auto\").", "hasChildren": false @@ -28439,11 +32193,17 @@ { "path": "channels.telegram.commands.nativeSkills", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Native Skill Commands", "help": "Override native skill commands for Telegram (bool or \"auto\").", "hasChildren": false @@ -28455,7 +32215,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Config Writes", "help": "Allow Telegram to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -28467,7 +32230,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Custom Commands", "help": "Additional Telegram bot menu commands (merged with native; conflicts ignored).", "hasChildren": true @@ -28515,7 +32281,10 @@ { "path": "channels.telegram.defaultTo", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -28555,7 +32324,10 @@ { "path": "channels.telegram.direct.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -28567,7 +32339,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -28816,7 +32593,10 @@ { "path": "channels.telegram.direct.*.topics.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -28848,7 +32628,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -28909,11 +32693,20 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "Telegram DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.telegram.allowFrom=[\"*\"].", "hasChildren": false @@ -29005,7 +32798,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Exec Approvals", "help": "Telegram-native exec approval routing and approver authorization. Enable this only when Telegram should act as an explicit exec-approval client for the selected bot account.", "hasChildren": true @@ -29017,7 +32813,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Exec Approval Agent Filter", "help": "Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `[\"main\", \"ops-agent\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram.", "hasChildren": true @@ -29039,7 +32838,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Exec Approval Approvers", "help": "Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs; prompts are only delivered to these approvers when target includes dm.", "hasChildren": true @@ -29047,7 +32849,10 @@ { "path": "channels.telegram.execApprovals.approvers.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -29061,7 +32866,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Exec Approvals Enabled", "help": "Enable Telegram exec approvals for this account. When false or unset, Telegram messages/buttons cannot approve exec requests.", "hasChildren": false @@ -29073,7 +32881,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "storage"], + "tags": [ + "channels", + "network", + "storage" + ], "label": "Telegram Exec Approval Session Filter", "help": "Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions.", "hasChildren": true @@ -29093,10 +32905,17 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["dm", "channel", "both"], + "enumValues": [ + "dm", + "channel", + "both" + ], "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Exec Approval Target", "help": "Controls where Telegram approval prompts are sent: \"dm\" sends to approver DMs (default), \"channel\" sends to the originating Telegram chat/topic, and \"both\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics.", "hasChildren": false @@ -29114,7 +32933,10 @@ { "path": "channels.telegram.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -29126,7 +32948,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -29166,7 +32992,10 @@ { "path": "channels.telegram.groups.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -29198,7 +33027,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -29437,7 +33270,10 @@ { "path": "channels.telegram.groups.*.topics.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -29469,7 +33305,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -29515,6 +33355,26 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.telegram.healthMonitor", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.telegram.healthMonitor.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.telegram.heartbeat", "kind": "channel", @@ -29590,7 +33450,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -29633,7 +33497,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram autoSelectFamily", "help": "Override Node autoSelectFamily for Telegram (true=enable, false=disable).", "hasChildren": false @@ -29643,7 +33510,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["ipv4first", "verbatim"], + "enumValues": [ + "ipv4first", + "verbatim" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -29664,7 +33534,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "ack", "minimal", "extensive"], + "enumValues": [ + "off", + "ack", + "minimal", + "extensive" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -29675,7 +33550,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "own", "all"], + "enumValues": [ + "off", + "own", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -29718,7 +33597,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "reliability"], + "tags": [ + "channels", + "network", + "reliability" + ], "label": "Telegram Retry Attempts", "help": "Max retry attempts for outbound Telegram API calls (default: 3).", "hasChildren": false @@ -29730,7 +33613,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "reliability"], + "tags": [ + "channels", + "network", + "reliability" + ], "label": "Telegram Retry Jitter", "help": "Jitter factor (0-1) applied to Telegram retry delays.", "hasChildren": false @@ -29742,7 +33629,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance", "reliability"], + "tags": [ + "channels", + "network", + "performance", + "reliability" + ], "label": "Telegram Retry Max Delay (ms)", "help": "Maximum retry delay cap in ms for Telegram outbound calls.", "hasChildren": false @@ -29754,7 +33646,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "reliability"], + "tags": [ + "channels", + "network", + "reliability" + ], "label": "Telegram Retry Min Delay (ms)", "help": "Minimum retry delay in ms for Telegram outbound calls.", "hasChildren": false @@ -29762,12 +33658,23 @@ { "path": "channels.telegram.streaming", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, - "enumValues": ["off", "partial", "block", "progress"], + "enumValues": [ + "off", + "partial", + "block", + "progress" + ], "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Streaming Mode", "help": "Unified Telegram stream preview mode: \"off\" | \"partial\" | \"block\" | \"progress\" (default: \"partial\"). \"progress\" maps to \"partial\" on Telegram. Legacy boolean/streamMode keys are auto-mapped.", "hasChildren": false @@ -29777,7 +33684,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "partial", "block"], + "enumValues": [ + "off", + "partial", + "block" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -29810,7 +33721,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "storage"], + "tags": [ + "channels", + "network", + "storage" + ], "label": "Telegram Thread Binding Enabled", "help": "Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set.", "hasChildren": false @@ -29822,7 +33737,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "storage"], + "tags": [ + "channels", + "network", + "storage" + ], "label": "Telegram Thread Binding Idle Timeout (hours)", "help": "Inactivity window in hours for Telegram bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set.", "hasChildren": false @@ -29834,7 +33753,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance", "storage"], + "tags": [ + "channels", + "network", + "performance", + "storage" + ], "label": "Telegram Thread Binding Max Age (hours)", "help": "Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.", "hasChildren": false @@ -29846,7 +33770,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "storage"], + "tags": [ + "channels", + "network", + "storage" + ], "label": "Telegram Thread-Bound ACP Spawn", "help": "Allow ACP spawns with thread=true to auto-bind Telegram current conversations when supported.", "hasChildren": false @@ -29858,7 +33786,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "storage"], + "tags": [ + "channels", + "network", + "storage" + ], "label": "Telegram Thread-Bound Subagent Spawn", "help": "Allow subagent spawns with thread=true to auto-bind Telegram current conversations when supported.", "hasChildren": false @@ -29870,7 +33802,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "Telegram API Timeout (seconds)", "help": "Max seconds before Telegram API requests are aborted (default: 500 per grammY).", "hasChildren": false @@ -29928,11 +33864,19 @@ { "path": "channels.telegram.webhookSecret", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -29982,7 +33926,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Tlon", "help": "Decentralized messaging on Urbit", "hasChildren": true @@ -30232,7 +34179,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["restricted", "open"], + "enumValues": [ + "restricted", + "open" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -30415,7 +34365,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Twitch", "help": "Twitch chat integration", "hasChildren": true @@ -30475,7 +34428,13 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["moderator", "owner", "vip", "subscriber", "all"], + "enumValues": [ + "moderator", + "owner", + "vip", + "subscriber", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -30544,7 +34503,10 @@ { "path": "channels.twitch.accounts.*.expiresIn", "kind": "channel", - "type": ["null", "number"], + "type": [ + "null", + "number" + ], "required": false, "deprecated": false, "sensitive": false, @@ -30616,7 +34578,13 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["moderator", "owner", "vip", "subscriber", "all"], + "enumValues": [ + "moderator", + "owner", + "vip", + "subscriber", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -30685,7 +34653,10 @@ { "path": "channels.twitch.expiresIn", "kind": "channel", - "type": ["null", "number"], + "type": [ + "null", + "number" + ], "required": false, "deprecated": false, "sensitive": false, @@ -30707,7 +34678,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["bullets", "code", "off"], + "enumValues": [ + "bullets", + "code", + "off" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -30780,7 +34755,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "WhatsApp", "help": "works with your own number; recommend a separate phone + eSIM.", "hasChildren": true @@ -30841,7 +34819,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["always", "mentions", "never"], + "enumValues": [ + "always", + "mentions", + "never" + ], "defaultValue": "mentions", "deprecated": false, "sensitive": false, @@ -30953,7 +34935,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -31005,7 +34990,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -31077,7 +35067,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -31264,6 +35258,26 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.whatsapp.accounts.*.healthMonitor", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.whatsapp.accounts.*.healthMonitor.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.whatsapp.accounts.*.heartbeat", "kind": "channel", @@ -31329,7 +35343,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -31441,7 +35459,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["always", "mentions", "never"], + "enumValues": [ + "always", + "mentions", + "never" + ], "defaultValue": "mentions", "deprecated": false, "sensitive": false, @@ -31583,7 +35605,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -31596,7 +35621,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "WhatsApp Config Writes", "help": "Allow WhatsApp to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -31609,7 +35637,11 @@ "defaultValue": 0, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "WhatsApp Message Debounce (ms)", "help": "Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable).", "hasChildren": false @@ -31649,11 +35681,20 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "WhatsApp DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.whatsapp.allowFrom=[\"*\"].", "hasChildren": false @@ -31723,7 +35764,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -31910,6 +35955,26 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.whatsapp.healthMonitor", + "kind": "channel", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "channels.whatsapp.healthMonitor.enabled", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.whatsapp.heartbeat", "kind": "channel", @@ -31975,7 +36040,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -32019,7 +36088,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "WhatsApp Self-Phone Mode", "help": "Same-phone setup (bot uses your personal WhatsApp number).", "hasChildren": false @@ -32051,7 +36123,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Zalo", "help": "Vietnam-focused messaging platform with Bot API.", "hasChildren": true @@ -32089,7 +36164,10 @@ { "path": "channels.zalo.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -32099,7 +36177,10 @@ { "path": "channels.zalo.accounts.*.botToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -32141,7 +36222,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -32170,7 +36256,10 @@ { "path": "channels.zalo.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -32182,7 +36271,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -32203,7 +36296,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -32272,7 +36369,10 @@ { "path": "channels.zalo.accounts.*.webhookSecret", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -32332,7 +36432,10 @@ { "path": "channels.zalo.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -32342,7 +36445,10 @@ { "path": "channels.zalo.botToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -32394,7 +36500,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -32423,7 +36534,10 @@ { "path": "channels.zalo.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -32435,7 +36549,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -32456,7 +36574,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -32525,7 +36647,10 @@ { "path": "channels.zalo.webhookSecret", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -32579,7 +36704,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Zalo Personal", "help": "Zalo personal account via QR code login.", "hasChildren": true @@ -32617,7 +36745,10 @@ { "path": "channels.zalouser.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -32639,7 +36770,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -32668,7 +36804,10 @@ { "path": "channels.zalouser.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -32680,7 +36819,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -32831,7 +36974,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -32890,7 +37037,10 @@ { "path": "channels.zalouser.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -32922,7 +37072,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -32951,7 +37106,10 @@ { "path": "channels.zalouser.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -32963,7 +37121,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -33114,7 +37276,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -33167,7 +37333,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "CLI", "help": "CLI presentation controls for local command output behavior such as banner and tagline style. Use this section to keep startup output aligned with operator preference without changing runtime behavior.", "hasChildren": true @@ -33179,7 +37347,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "CLI Banner", "help": "CLI startup banner controls for title/version line and tagline style behavior. Keep banner enabled for fast version/context checks, then tune tagline mode to your preferred noise level.", "hasChildren": true @@ -33191,7 +37361,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "CLI Banner Tagline Mode", "help": "Controls tagline style in the CLI startup banner: \"random\" (default) picks from the rotating tagline pool, \"default\" always shows the neutral default tagline, and \"off\" hides tagline text while keeping the banner version line.", "hasChildren": false @@ -33209,7 +37381,9 @@ }, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Commands", "help": "Controls chat command surfaces, owner gating, and elevated command access behavior across providers. Keep defaults unless you need stricter operator controls or broader command availability.", "hasChildren": true @@ -33221,7 +37395,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Command Elevated Access Rules", "help": "Defines elevated command allow rules by channel and sender for owner-level command surfaces. Use narrow provider-specific identities so privileged commands are not exposed to broad chat audiences.", "hasChildren": true @@ -33239,7 +37415,10 @@ { "path": "commands.allowFrom.*.*", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -33253,7 +37432,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Allow Bash Chat Command", "help": "Allow bash chat command (`!`; `/bash` alias) to run host shell commands (default: false; requires tools.elevated).", "hasChildren": false @@ -33265,7 +37446,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Bash Foreground Window (ms)", "help": "How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately).", "hasChildren": false @@ -33277,7 +37460,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Allow /config", "help": "Allow /config chat command to read/write config on disk (default: false).", "hasChildren": false @@ -33289,7 +37474,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Allow /debug", "help": "Allow /debug chat command for runtime-only overrides (default: false).", "hasChildren": false @@ -33297,11 +37484,16 @@ { "path": "commands.native", "kind": "core", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Native Commands", "help": "Registers native slash/menu commands with channels that support command registration (Discord, Slack, Telegram). Keep enabled for discoverability unless you intentionally run text-only command workflows.", "hasChildren": false @@ -33309,11 +37501,16 @@ { "path": "commands.nativeSkills", "kind": "core", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Native Skill Commands", "help": "Registers native skill commands so users can invoke skills directly from provider command menus where supported. Keep aligned with your skill policy so exposed commands match what operators expect.", "hasChildren": false @@ -33325,7 +37522,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Command Owners", "help": "Explicit owner allowlist for owner-only tools/commands. Use channel-native IDs (optionally prefixed like \"whatsapp:+15551234567\"). '*' is ignored.", "hasChildren": true @@ -33333,7 +37532,10 @@ { "path": "commands.ownerAllowFrom.*", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -33345,11 +37547,16 @@ "kind": "core", "type": "string", "required": true, - "enumValues": ["raw", "hash"], + "enumValues": [ + "raw", + "hash" + ], "defaultValue": "raw", "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Owner ID Display", "help": "Controls how owner IDs are rendered in the system prompt. Allowed values: raw, hash. Default: raw.", "hasChildren": false @@ -33361,7 +37568,11 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["access", "auth", "security"], + "tags": [ + "access", + "auth", + "security" + ], "label": "Owner ID Hash Secret", "help": "Optional secret used to HMAC hash owner IDs when ownerDisplay=hash. Prefer env substitution.", "hasChildren": false @@ -33374,7 +37585,9 @@ "defaultValue": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Allow Restart", "help": "Allow /restart and gateway restart tool actions (default: true).", "hasChildren": false @@ -33386,7 +37599,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Text Commands", "help": "Enables text-command parsing in chat input in addition to native command surfaces where available. Keep this enabled for compatibility across channels that do not support native command registration.", "hasChildren": false @@ -33398,7 +37613,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Use Access Groups", "help": "Enforce access-group allowlists/policies for commands.", "hasChildren": false @@ -33410,7 +37627,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], + "tags": [ + "automation" + ], "label": "Cron", "help": "Global scheduler settings for stored cron jobs, run concurrency, delivery fallback, and run-session retention. Keep defaults unless you are scaling job volume or integrating external webhook receivers.", "hasChildren": true @@ -33422,7 +37641,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], + "tags": [ + "automation" + ], "label": "Cron Enabled", "help": "Enables cron job execution for stored schedules managed by the gateway. Keep enabled for normal reminder/automation flows, and disable only to pause all cron execution without deleting jobs.", "hasChildren": false @@ -33482,7 +37703,10 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["announce", "webhook"], + "enumValues": [ + "announce", + "webhook" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -33523,7 +37747,10 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["announce", "webhook"], + "enumValues": [ + "announce", + "webhook" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -33546,7 +37773,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "performance"], + "tags": [ + "automation", + "performance" + ], "label": "Cron Max Concurrent Runs", "help": "Limits how many cron jobs can execute at the same time when multiple schedules fire together. Use lower values to protect CPU/memory under heavy automation load, or raise carefully for higher throughput.", "hasChildren": false @@ -33558,7 +37788,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "reliability"], + "tags": [ + "automation", + "reliability" + ], "label": "Cron Retry Policy", "help": "Overrides the default retry policy for one-shot jobs when they fail with transient errors (rate limit, overloaded, network, server_error). Omit to use defaults: maxAttempts 3, backoffMs [30000, 60000, 300000], retry all transient types.", "hasChildren": true @@ -33570,7 +37803,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "reliability"], + "tags": [ + "automation", + "reliability" + ], "label": "Cron Retry Backoff (ms)", "help": "Backoff delays in ms for each retry attempt (default: [30000, 60000, 300000]). Use shorter values for faster retries.", "hasChildren": true @@ -33592,7 +37828,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "performance", "reliability"], + "tags": [ + "automation", + "performance", + "reliability" + ], "label": "Cron Retry Max Attempts", "help": "Max retries for one-shot jobs on transient errors before permanent disable (default: 3).", "hasChildren": false @@ -33604,7 +37844,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "reliability"], + "tags": [ + "automation", + "reliability" + ], "label": "Cron Retry Error Types", "help": "Error types to retry: rate_limit, overloaded, network, timeout, server_error. Use to restrict which errors trigger retries; omit to retry all transient types.", "hasChildren": true @@ -33614,7 +37857,13 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["rate_limit", "overloaded", "network", "timeout", "server_error"], + "enumValues": [ + "rate_limit", + "overloaded", + "network", + "timeout", + "server_error" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -33627,7 +37876,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], + "tags": [ + "automation" + ], "label": "Cron Run Log Pruning", "help": "Pruning controls for per-job cron run history files under `cron/runs/.jsonl`, including size and line retention.", "hasChildren": true @@ -33639,7 +37890,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], + "tags": [ + "automation" + ], "label": "Cron Run Log Keep Lines", "help": "How many trailing run-log lines to retain when a file exceeds maxBytes (default `2000`). Increase for longer forensic history or lower for smaller disks.", "hasChildren": false @@ -33647,11 +37900,17 @@ { "path": "cron.runLog.maxBytes", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "performance"], + "tags": [ + "automation", + "performance" + ], "label": "Cron Run Log Max Bytes", "help": "Maximum bytes per cron run-log file before pruning rewrites to the last keepLines entries (for example `2mb`, default `2000000`).", "hasChildren": false @@ -33659,11 +37918,17 @@ { "path": "cron.sessionRetention", "kind": "core", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "storage"], + "tags": [ + "automation", + "storage" + ], "label": "Cron Session Retention", "help": "Controls how long completed cron run sessions are kept before pruning (`24h`, `7d`, `1h30m`, or `false` to disable pruning; default: `24h`). Use shorter retention to reduce storage growth on high-frequency schedules.", "hasChildren": false @@ -33675,7 +37940,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "storage"], + "tags": [ + "automation", + "storage" + ], "label": "Cron Store Path", "help": "Path to the cron job store file used to persist scheduled jobs across restarts. Set an explicit path only when you need custom storage layout, backups, or mounted volumes.", "hasChildren": false @@ -33687,7 +37955,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], + "tags": [ + "automation" + ], "label": "Cron Legacy Webhook (Deprecated)", "help": "Deprecated legacy fallback webhook URL used only for old jobs with `notify=true`. Migrate to per-job delivery using `delivery.mode=\"webhook\"` plus `delivery.to`, and avoid relying on this global field.", "hasChildren": false @@ -33695,11 +37965,18 @@ { "path": "cron.webhookToken", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "automation", "security"], + "tags": [ + "auth", + "automation", + "security" + ], "label": "Cron Webhook Bearer Token", "help": "Bearer token attached to cron webhook POST deliveries when webhook mode is used. Prefer secret/env substitution and rotate this token regularly if shared webhook endpoints are internet-reachable.", "hasChildren": true @@ -33741,7 +38018,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "Diagnostics", "help": "Diagnostics controls for targeted tracing, telemetry export, and cache inspection during debugging. Keep baseline diagnostics minimal in production and enable deeper signals only when investigating issues.", "hasChildren": true @@ -33753,7 +38032,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "storage"], + "tags": [ + "observability", + "storage" + ], "label": "Cache Trace", "help": "Cache-trace logging settings for observing cache decisions and payload context in embedded runs. Enable this temporarily for debugging and disable afterward to reduce sensitive log footprint.", "hasChildren": true @@ -33765,7 +38047,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "storage"], + "tags": [ + "observability", + "storage" + ], "label": "Cache Trace Enabled", "help": "Log cache trace snapshots for embedded agent runs (default: false).", "hasChildren": false @@ -33777,7 +38062,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "storage"], + "tags": [ + "observability", + "storage" + ], "label": "Cache Trace File Path", "help": "JSONL output path for cache trace logs (default: $OPENCLAW_STATE_DIR/logs/cache-trace.jsonl).", "hasChildren": false @@ -33789,7 +38077,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "storage"], + "tags": [ + "observability", + "storage" + ], "label": "Cache Trace Include Messages", "help": "Include full message payloads in trace output (default: true).", "hasChildren": false @@ -33801,7 +38092,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "storage"], + "tags": [ + "observability", + "storage" + ], "label": "Cache Trace Include Prompt", "help": "Include prompt text in trace output (default: true).", "hasChildren": false @@ -33813,7 +38107,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "storage"], + "tags": [ + "observability", + "storage" + ], "label": "Cache Trace Include System", "help": "Include system prompt in trace output (default: true).", "hasChildren": false @@ -33825,7 +38122,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "Diagnostics Enabled", "help": "Master toggle for diagnostics instrumentation output in logs and telemetry wiring paths. Keep enabled for normal observability, and disable only in tightly constrained environments.", "hasChildren": false @@ -33837,7 +38136,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "Diagnostics Flags", "help": "Enable targeted diagnostics logs by flag (e.g. [\"telegram.http\"]). Supports wildcards like \"telegram.*\" or \"*\".", "hasChildren": true @@ -33859,7 +38160,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "OpenTelemetry", "help": "OpenTelemetry export settings for traces, metrics, and logs emitted by gateway components. Use this when integrating with centralized observability backends and distributed tracing pipelines.", "hasChildren": true @@ -33871,7 +38174,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "OpenTelemetry Enabled", "help": "Enables OpenTelemetry export pipeline for traces, metrics, and logs based on configured endpoint/protocol settings. Keep disabled unless your collector endpoint and auth are fully configured.", "hasChildren": false @@ -33883,7 +38188,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "OpenTelemetry Endpoint", "help": "Collector endpoint URL used for OpenTelemetry export transport, including scheme and port. Use a reachable, trusted collector endpoint and monitor ingestion errors after rollout.", "hasChildren": false @@ -33895,7 +38202,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "performance"], + "tags": [ + "observability", + "performance" + ], "label": "OpenTelemetry Flush Interval (ms)", "help": "Interval in milliseconds for periodic telemetry flush from buffers to the collector. Increase to reduce export chatter, or lower for faster visibility during active incident response.", "hasChildren": false @@ -33907,7 +38217,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "OpenTelemetry Headers", "help": "Additional HTTP/gRPC metadata headers sent with OpenTelemetry export requests, often used for tenant auth or routing. Keep secrets in env-backed values and avoid unnecessary header sprawl.", "hasChildren": true @@ -33929,7 +38241,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "OpenTelemetry Logs Enabled", "help": "Enable log signal export through OpenTelemetry in addition to local logging sinks. Use this when centralized log correlation is required across services and agents.", "hasChildren": false @@ -33941,7 +38255,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "OpenTelemetry Metrics Enabled", "help": "Enable metrics signal export to the configured OpenTelemetry collector endpoint. Keep enabled for runtime health dashboards, and disable only if metric volume must be minimized.", "hasChildren": false @@ -33953,7 +38269,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "OpenTelemetry Protocol", "help": "OTel transport protocol for telemetry export: \"http/protobuf\" or \"grpc\" depending on collector support. Use the protocol your observability backend expects to avoid dropped telemetry payloads.", "hasChildren": false @@ -33965,7 +38283,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "OpenTelemetry Trace Sample Rate", "help": "Trace sampling rate (0-1) controlling how much trace traffic is exported to observability backends. Lower rates reduce overhead/cost, while higher rates improve debugging fidelity.", "hasChildren": false @@ -33977,7 +38297,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "OpenTelemetry Service Name", "help": "Service name reported in telemetry resource attributes to identify this gateway instance in observability backends. Use stable names so dashboards and alerts remain consistent over deployments.", "hasChildren": false @@ -33989,7 +38311,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "OpenTelemetry Traces Enabled", "help": "Enable trace signal export to the configured OpenTelemetry collector endpoint. Keep enabled when latency/debug tracing is needed, and disable if you only want metrics/logs.", "hasChildren": false @@ -34001,7 +38325,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "storage"], + "tags": [ + "observability", + "storage" + ], "label": "Stuck Session Warning Threshold (ms)", "help": "Age threshold in milliseconds for emitting stuck-session warnings while a session remains in processing state. Increase for long multi-tool turns to reduce false positives; decrease for faster hang detection.", "hasChildren": false @@ -34013,7 +38340,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Discovery", "help": "Service discovery settings for local mDNS advertisement and optional wide-area presence signaling. Keep discovery scoped to expected networks to avoid leaking service metadata.", "hasChildren": true @@ -34025,7 +38354,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "mDNS Discovery", "help": "mDNS discovery configuration group for local network advertisement and discovery behavior tuning. Keep minimal mode for routine LAN discovery unless extra metadata is required.", "hasChildren": true @@ -34035,10 +38366,16 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["off", "minimal", "full"], + "enumValues": [ + "off", + "minimal", + "full" + ], "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "mDNS Discovery Mode", "help": "mDNS broadcast mode (\"minimal\" default, \"full\" includes cliPath/sshPort, \"off\" disables mDNS).", "hasChildren": false @@ -34050,7 +38387,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Wide-area Discovery", "help": "Wide-area discovery configuration group for exposing discovery signals beyond local-link scopes. Enable only in deployments that intentionally aggregate gateway presence across sites.", "hasChildren": true @@ -34062,7 +38401,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Wide-area Discovery Domain", "help": "Optional unicast DNS-SD domain for wide-area discovery, such as openclaw.internal. Use this when you intentionally publish gateway discovery beyond local mDNS scopes.", "hasChildren": false @@ -34074,7 +38415,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Wide-area Discovery Enabled", "help": "Enables wide-area discovery signaling when your environment needs non-local gateway discovery. Keep disabled unless cross-network discovery is operationally required.", "hasChildren": false @@ -34086,7 +38429,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Environment", "help": "Environment import and override settings used to supply runtime variables to the gateway process. Use this section to control shell-env loading and explicit variable injection behavior.", "hasChildren": true @@ -34108,7 +38453,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Shell Environment Import", "help": "Shell environment import controls for loading variables from your login shell during startup. Keep this enabled when you depend on profile-defined secrets or PATH customizations.", "hasChildren": true @@ -34120,7 +38467,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Shell Environment Import Enabled", "help": "Enables loading environment variables from the user shell profile during startup initialization. Keep enabled for developer machines, or disable in locked-down service environments with explicit env management.", "hasChildren": false @@ -34132,7 +38481,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Shell Environment Import Timeout (ms)", "help": "Maximum time in milliseconds allowed for shell environment resolution before fallback behavior applies. Use tighter timeouts for faster startup, or increase when shell initialization is heavy.", "hasChildren": false @@ -34144,7 +38495,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Environment Variable Overrides", "help": "Explicit key/value environment variable overrides merged into runtime process environment for OpenClaw. Use this for deterministic env configuration instead of relying only on shell profile side effects.", "hasChildren": true @@ -34166,7 +38519,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gateway", "help": "Gateway runtime surface for bind mode, auth, control UI, remote transport, and operational safety controls. Keep conservative defaults unless you intentionally expose the gateway beyond trusted local interfaces.", "hasChildren": true @@ -34178,7 +38533,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "network", "reliability"], + "tags": [ + "access", + "network", + "reliability" + ], "label": "Gateway Allow x-real-ip Fallback", "help": "Enables x-real-ip fallback when x-forwarded-for is missing in proxy scenarios. Keep disabled unless your ingress stack requires this compatibility behavior.", "hasChildren": false @@ -34190,7 +38549,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Auth", "help": "Authentication policy for gateway HTTP/WebSocket access including mode, credentials, trusted-proxy behavior, and rate limiting. Keep auth enabled for every non-loopback deployment.", "hasChildren": true @@ -34202,7 +38563,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "network"], + "tags": [ + "access", + "network" + ], "label": "Gateway Auth Allow Tailscale Identity", "help": "Allows trusted Tailscale identity paths to satisfy gateway auth checks when configured. Use this only when your tailnet identity posture is strong and operator workflows depend on it.", "hasChildren": false @@ -34214,7 +38578,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Auth Mode", "help": "Gateway auth mode: \"none\", \"token\", \"password\", or \"trusted-proxy\" depending on your edge architecture. Use token/password for direct exposure, and trusted-proxy only behind hardened identity-aware proxies.", "hasChildren": false @@ -34222,11 +38588,19 @@ { "path": "gateway.auth.password", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["access", "auth", "network", "security"], + "tags": [ + "access", + "auth", + "network", + "security" + ], "label": "Gateway Password", "help": "Required for Tailscale funnel.", "hasChildren": true @@ -34268,7 +38642,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "performance"], + "tags": [ + "network", + "performance" + ], "label": "Gateway Auth Rate Limit", "help": "Login/auth attempt throttling controls to reduce credential brute-force risk at the gateway boundary. Keep enabled in exposed environments and tune thresholds to your traffic baseline.", "hasChildren": true @@ -34316,11 +38693,19 @@ { "path": "gateway.auth.token", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["access", "auth", "network", "security"], + "tags": [ + "access", + "auth", + "network", + "security" + ], "label": "Gateway Token", "help": "Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.", "hasChildren": true @@ -34362,7 +38747,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Trusted Proxy Auth", "help": "Trusted-proxy auth header mapping for upstream identity providers that inject user claims. Use only with known proxy CIDRs and strict header allowlists to prevent spoofed identity headers.", "hasChildren": true @@ -34424,7 +38811,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Bind Mode", "help": "Network bind profile: \"auto\", \"lan\", \"loopback\", \"custom\", or \"tailnet\" to control interface exposure. Keep \"loopback\" or \"auto\" for safest local operation unless external clients must connect.", "hasChildren": false @@ -34436,11 +38825,43 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "reliability"], + "tags": [ + "network", + "reliability" + ], "label": "Gateway Channel Health Check Interval (min)", "help": "Interval in minutes for automatic channel health probing and status updates. Use lower intervals for faster detection, or higher intervals to reduce periodic probe noise.", "hasChildren": false }, + { + "path": "gateway.channelMaxRestartsPerHour", + "kind": "core", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "network", + "performance" + ], + "label": "Gateway Channel Max Restarts Per Hour", + "help": "Maximum number of health-monitor-initiated channel restarts allowed within a rolling one-hour window. Once hit, further restarts are skipped until the window expires. Default: 10.", + "hasChildren": false + }, + { + "path": "gateway.channelStaleEventThresholdMinutes", + "kind": "core", + "type": "integer", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "network" + ], + "label": "Gateway Channel Stale Event Threshold (min)", + "help": "How many minutes a connected channel can go without receiving any event before the health monitor treats it as a stale socket and triggers a restart. Default: 30.", + "hasChildren": false + }, { "path": "gateway.controlUi", "kind": "core", @@ -34448,7 +38869,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Control UI", "help": "Control UI hosting settings including enablement, pathing, and browser-origin/auth hardening behavior. Keep UI exposure minimal and pair with strong auth controls before internet-facing deployments.", "hasChildren": true @@ -34460,7 +38883,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "network"], + "tags": [ + "access", + "network" + ], "label": "Control UI Allowed Origins", "help": "Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled.", "hasChildren": true @@ -34482,7 +38908,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "advanced", "network", "security"], + "tags": [ + "access", + "advanced", + "network", + "security" + ], "label": "Insecure Control UI Auth Toggle", "help": "Loosens strict browser auth checks for Control UI when you must run a non-standard setup. Keep this off unless you trust your network and proxy path, because impersonation risk is higher.", "hasChildren": false @@ -34494,7 +38925,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "storage"], + "tags": [ + "network", + "storage" + ], "label": "Control UI Base Path", "help": "Optional URL prefix where the Control UI is served (e.g. /openclaw).", "hasChildren": false @@ -34506,7 +38940,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "advanced", "network", "security"], + "tags": [ + "access", + "advanced", + "network", + "security" + ], "label": "Dangerously Allow Host-Header Origin Fallback", "help": "DANGEROUS toggle that enables Host-header based origin fallback for Control UI/WebChat websocket checks. This mode is supported when your deployment intentionally relies on Host-header origin policy; explicit gateway.controlUi.allowedOrigins remains the recommended hardened default.", "hasChildren": false @@ -34518,7 +38957,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "advanced", "network", "security"], + "tags": [ + "access", + "advanced", + "network", + "security" + ], "label": "Dangerously Disable Control UI Device Auth", "help": "Disables Control UI device identity checks and relies on token/password only. Use only for short-lived debugging on trusted networks, then turn it off immediately.", "hasChildren": false @@ -34530,7 +38974,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Control UI Enabled", "help": "Enables serving the gateway Control UI from the gateway HTTP process when true. Keep enabled for local administration, and disable when an external control surface replaces it.", "hasChildren": false @@ -34542,7 +38988,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Control UI Assets Root", "help": "Optional filesystem root for Control UI assets (defaults to dist/control-ui).", "hasChildren": false @@ -34554,7 +39002,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Custom Bind Host", "help": "Explicit bind host/IP used when gateway.bind is set to custom for manual interface targeting. Use a precise address and avoid wildcard binds unless external exposure is required.", "hasChildren": false @@ -34566,7 +39016,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway HTTP API", "help": "Gateway HTTP API configuration grouping endpoint toggles and transport-facing API exposure controls. Keep only required endpoints enabled to reduce attack surface.", "hasChildren": true @@ -34578,7 +39030,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway HTTP Endpoints", "help": "HTTP endpoint feature toggles under the gateway API surface for compatibility routes and optional integrations. Enable endpoints intentionally and monitor access patterns after rollout.", "hasChildren": true @@ -34600,7 +39054,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "OpenAI Chat Completions Endpoint", "help": "Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).", "hasChildren": false @@ -34612,7 +39068,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "network"], + "tags": [ + "media", + "network" + ], "label": "OpenAI Chat Completions Image Limits", "help": "Image fetch/validation controls for OpenAI-compatible `image_url` parts.", "hasChildren": true @@ -34624,7 +39083,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "media", "network"], + "tags": [ + "access", + "media", + "network" + ], "label": "OpenAI Chat Completions Image MIME Allowlist", "help": "Allowed MIME types for `image_url` parts (case-insensitive list).", "hasChildren": true @@ -34646,7 +39109,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "media", "network"], + "tags": [ + "access", + "media", + "network" + ], "label": "OpenAI Chat Completions Allow Image URLs", "help": "Allow server-side URL fetches for `image_url` parts (default: false; data URIs remain supported).", "hasChildren": false @@ -34658,7 +39125,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "network", "performance"], + "tags": [ + "media", + "network", + "performance" + ], "label": "OpenAI Chat Completions Image Max Bytes", "help": "Max bytes per fetched/decoded `image_url` image (default: 10MB).", "hasChildren": false @@ -34670,7 +39141,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "network", "performance", "storage"], + "tags": [ + "media", + "network", + "performance", + "storage" + ], "label": "OpenAI Chat Completions Image Max Redirects", "help": "Max HTTP redirects allowed when fetching `image_url` URLs (default: 3).", "hasChildren": false @@ -34682,7 +39158,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "network", "performance"], + "tags": [ + "media", + "network", + "performance" + ], "label": "OpenAI Chat Completions Image Timeout (ms)", "help": "Timeout in milliseconds for `image_url` URL fetches (default: 10000).", "hasChildren": false @@ -34694,7 +39174,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "media", "network"], + "tags": [ + "access", + "media", + "network" + ], "label": "OpenAI Chat Completions Image URL Allowlist", "help": "Optional hostname allowlist for `image_url` URL fetches; supports exact hosts and `*.example.com` wildcards.", "hasChildren": true @@ -34716,7 +39200,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "performance"], + "tags": [ + "network", + "performance" + ], "label": "OpenAI Chat Completions Max Body Bytes", "help": "Max request body size in bytes for `/v1/chat/completions` (default: 20MB).", "hasChildren": false @@ -34728,7 +39215,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "network", "performance"], + "tags": [ + "media", + "network", + "performance" + ], "label": "OpenAI Chat Completions Max Image Parts", "help": "Max number of `image_url` parts accepted from the latest user message (default: 8).", "hasChildren": false @@ -34740,7 +39231,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "network", "performance"], + "tags": [ + "media", + "network", + "performance" + ], "label": "OpenAI Chat Completions Max Total Image Bytes", "help": "Max cumulative decoded bytes across all `image_url` parts in one request (default: 20MB).", "hasChildren": false @@ -35022,7 +39517,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway HTTP Security Headers", "help": "Optional HTTP response security headers applied by the gateway process itself. Prefer setting these at your reverse proxy when TLS terminates there.", "hasChildren": true @@ -35030,11 +39527,16 @@ { "path": "gateway.http.securityHeaders.strictTransportSecurity", "kind": "core", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Strict Transport Security Header", "help": "Value for the Strict-Transport-Security response header. Set only on HTTPS origins that you fully control; use false to explicitly disable.", "hasChildren": false @@ -35046,7 +39548,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Mode", "help": "Gateway operation mode: \"local\" runs channels and agent runtime on this host, while \"remote\" connects through remote transport. Keep \"local\" unless you intentionally run a split remote gateway topology.", "hasChildren": false @@ -35068,7 +39572,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "network"], + "tags": [ + "access", + "network" + ], "label": "Gateway Node Allowlist (Extra Commands)", "help": "Extra node.invoke commands to allow beyond the gateway defaults (array of command strings). Enabling dangerous commands here is a security-sensitive override and is flagged by `openclaw security audit`.", "hasChildren": true @@ -35100,7 +39607,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Node Browser Mode", "help": "Node browser routing (\"auto\" = pick single connected browser node, \"manual\" = require node param, \"off\" = disable).", "hasChildren": false @@ -35112,7 +39621,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Node Browser Pin", "help": "Pin browser routing to a specific node id or name (optional).", "hasChildren": false @@ -35124,7 +39635,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "network"], + "tags": [ + "access", + "network" + ], "label": "Gateway Node Denylist", "help": "Node command names to block even if present in node claims or default allowlist (exact command-name matching only, e.g. `system.run`; does not inspect shell text inside that command).", "hasChildren": true @@ -35146,7 +39660,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Port", "help": "TCP port used by the gateway listener for API, control UI, and channel-facing ingress paths. Use a dedicated port and avoid collisions with reverse proxies or local developer services.", "hasChildren": false @@ -35158,7 +39674,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Push Delivery", "help": "Push-delivery settings used by the gateway when it needs to wake or notify paired devices. Configure relay-backed APNs here for official iOS builds; direct APNs auth remains env-based for local/manual builds.", "hasChildren": true @@ -35170,7 +39688,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway APNs Delivery", "help": "APNs delivery settings for iOS devices paired to this gateway. Use relay settings for official/TestFlight builds that register through the external push relay.", "hasChildren": true @@ -35182,7 +39702,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway APNs Relay", "help": "External relay settings for relay-backed APNs sends. The gateway uses this relay for push.test, wake nudges, and reconnect wakes after a paired official iOS build publishes a relay-backed registration.", "hasChildren": true @@ -35194,7 +39716,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "network"], + "tags": [ + "advanced", + "network" + ], "label": "Gateway APNs Relay Base URL", "help": "Base HTTPS URL for the external APNs relay service used by official/TestFlight iOS builds. Keep this aligned with the relay URL baked into the iOS build so registration and send traffic hit the same deployment.", "hasChildren": false @@ -35206,7 +39731,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "performance"], + "tags": [ + "network", + "performance" + ], "label": "Gateway APNs Relay Timeout (ms)", "help": "Timeout in milliseconds for relay send requests from the gateway to the APNs relay (default: 10000). Increase for slower relays or networks, or lower to fail wake attempts faster.", "hasChildren": false @@ -35218,7 +39746,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "reliability"], + "tags": [ + "network", + "reliability" + ], "label": "Config Reload", "help": "Live config-reload policy for how edits are applied and when full restarts are triggered. Keep hybrid behavior for safest operational updates unless debugging reload internals.", "hasChildren": true @@ -35230,7 +39761,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "performance", "reliability"], + "tags": [ + "network", + "performance", + "reliability" + ], "label": "Config Reload Debounce (ms)", "help": "Debounce window (ms) before applying config changes.", "hasChildren": false @@ -35242,7 +39777,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "reliability"], + "tags": [ + "network", + "reliability" + ], "label": "Config Reload Mode", "help": "Controls how config edits are applied: \"off\" ignores live edits, \"restart\" always restarts, \"hot\" applies in-process, and \"hybrid\" tries hot then restarts if required. Keep \"hybrid\" for safest routine updates.", "hasChildren": false @@ -35254,7 +39792,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Remote Gateway", "help": "Remote gateway connection settings for direct or SSH transport when this instance proxies to another runtime host. Use remote mode only when split-host operation is intentionally configured.", "hasChildren": true @@ -35262,11 +39802,18 @@ { "path": "gateway.remote.password", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "network", "security"], + "tags": [ + "auth", + "network", + "security" + ], "label": "Remote Gateway Password", "help": "Password credential used for remote gateway authentication when password mode is enabled. Keep this secret managed externally and avoid plaintext values in committed config.", "hasChildren": true @@ -35308,7 +39855,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Remote Gateway SSH Identity", "help": "Optional SSH identity file path (passed to ssh -i).", "hasChildren": false @@ -35320,7 +39869,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Remote Gateway SSH Target", "help": "Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.", "hasChildren": false @@ -35332,7 +39883,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["auth", "network", "security"], + "tags": [ + "auth", + "network", + "security" + ], "label": "Remote Gateway TLS Fingerprint", "help": "Expected sha256 TLS fingerprint for the remote gateway (pin to avoid MITM).", "hasChildren": false @@ -35340,11 +39895,18 @@ { "path": "gateway.remote.token", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "network", "security"], + "tags": [ + "auth", + "network", + "security" + ], "label": "Remote Gateway Token", "help": "Bearer token used to authenticate this client to a remote gateway in token-auth deployments. Store via secret/env substitution and rotate alongside remote gateway auth changes.", "hasChildren": true @@ -35386,7 +39948,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Remote Gateway Transport", "help": "Remote connection transport: \"direct\" uses configured URL connectivity, while \"ssh\" tunnels through SSH. Use SSH when you need encrypted tunnel semantics without exposing remote ports.", "hasChildren": false @@ -35398,7 +39962,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Remote Gateway URL", "help": "Remote Gateway WebSocket URL (ws:// or wss://).", "hasChildren": false @@ -35410,7 +39976,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Tailscale", "help": "Tailscale integration settings for Serve/Funnel exposure and lifecycle handling on gateway start/exit. Keep off unless your deployment intentionally relies on Tailscale ingress.", "hasChildren": true @@ -35422,7 +39990,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Tailscale Mode", "help": "Tailscale publish mode: \"off\", \"serve\", or \"funnel\" for private or public exposure paths. Use \"serve\" for tailnet-only access and \"funnel\" only when public internet reachability is required.", "hasChildren": false @@ -35434,7 +40004,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Tailscale Reset on Exit", "help": "Resets Tailscale Serve/Funnel state on gateway exit to avoid stale published routes after shutdown. Keep enabled unless another controller manages publish lifecycle outside the gateway.", "hasChildren": false @@ -35446,7 +40018,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway TLS", "help": "TLS certificate and key settings for terminating HTTPS directly in the gateway process. Use explicit certificates in production and avoid plaintext exposure on untrusted networks.", "hasChildren": true @@ -35458,7 +40032,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway TLS Auto-Generate Cert", "help": "Auto-generates a local TLS certificate/key pair when explicit files are not configured. Use only for local/dev setups and replace with real certificates for production traffic.", "hasChildren": false @@ -35470,7 +40046,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "storage"], + "tags": [ + "network", + "storage" + ], "label": "Gateway TLS CA Path", "help": "Optional CA bundle path for client verification or custom trust-chain requirements at the gateway edge. Use this when private PKI or custom certificate chains are part of deployment.", "hasChildren": false @@ -35482,7 +40061,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "storage"], + "tags": [ + "network", + "storage" + ], "label": "Gateway TLS Certificate Path", "help": "Filesystem path to the TLS certificate file used by the gateway when TLS is enabled. Use managed certificate paths and keep renewal automation aligned with this location.", "hasChildren": false @@ -35494,7 +40076,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway TLS Enabled", "help": "Enables TLS termination at the gateway listener so clients connect over HTTPS/WSS directly. Keep enabled for direct internet exposure or any untrusted network boundary.", "hasChildren": false @@ -35506,7 +40090,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "storage"], + "tags": [ + "network", + "storage" + ], "label": "Gateway TLS Key Path", "help": "Filesystem path to the TLS private key file used by the gateway when TLS is enabled. Keep this key file permission-restricted and rotate per your security policy.", "hasChildren": false @@ -35518,7 +40105,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Tool Exposure Policy", "help": "Gateway-level tool exposure allow/deny policy that can restrict runtime tool availability independent of agent/tool profiles. Use this for coarse emergency controls and production hardening.", "hasChildren": true @@ -35530,7 +40119,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "network"], + "tags": [ + "access", + "network" + ], "label": "Gateway Tool Allowlist", "help": "Explicit gateway-level tool allowlist when you want a narrow set of tools available at runtime. Use this for locked-down environments where tool scope must be tightly controlled.", "hasChildren": true @@ -35552,7 +40144,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "network"], + "tags": [ + "access", + "network" + ], "label": "Gateway Tool Denylist", "help": "Explicit gateway-level tool denylist to block risky tools even if lower-level policies allow them. Use deny rules for emergency response and defense-in-depth hardening.", "hasChildren": true @@ -35574,7 +40169,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Trusted Proxy CIDRs", "help": "CIDR/IP allowlist of upstream proxies permitted to provide forwarded client identity headers. Keep this list narrow so untrusted hops cannot impersonate users.", "hasChildren": true @@ -35596,7 +40193,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hooks", "help": "Inbound webhook automation surface for mapping external events into wake or agent actions in OpenClaw. Keep this locked down with explicit token/session/agent controls before exposing it beyond trusted networks.", "hasChildren": true @@ -35608,7 +40207,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Hooks Allowed Agent IDs", "help": "Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents.", "hasChildren": true @@ -35630,7 +40231,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Hooks Allowed Session Key Prefixes", "help": "Allowlist of accepted session-key prefixes for inbound hook requests when caller-provided keys are enabled. Use narrow prefixes to prevent arbitrary session-key injection.", "hasChildren": true @@ -35652,7 +40256,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Hooks Allow Request Session Key", "help": "Allows callers to supply a session key in hook requests when true, enabling caller-controlled routing. Keep false unless trusted integrators explicitly need custom session threading.", "hasChildren": false @@ -35664,7 +40271,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Hooks Default Session Key", "help": "Fallback session key used for hook deliveries when a request does not provide one through allowed channels. Use a stable but scoped key to avoid mixing unrelated automation conversations.", "hasChildren": false @@ -35676,7 +40285,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hooks Enabled", "help": "Enables the hooks endpoint and mapping execution pipeline for inbound webhook requests. Keep disabled unless you are actively routing external events into the gateway.", "hasChildren": false @@ -35688,7 +40299,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook", "help": "Gmail push integration settings used for Pub/Sub notifications and optional local callback serving. Keep this scoped to dedicated Gmail automation accounts where possible.", "hasChildren": true @@ -35700,7 +40313,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Account", "help": "Google account identifier used for Gmail watch/subscription operations in this hook integration. Use a dedicated automation mailbox account to isolate operational permissions.", "hasChildren": false @@ -35712,7 +40327,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Gmail Hook Allow Unsafe External Content", "help": "Allows less-sanitized external Gmail content to pass into processing when enabled. Keep disabled for safer defaults, and enable only for trusted mail streams with controlled transforms.", "hasChildren": false @@ -35724,7 +40341,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Callback URL", "help": "Public callback URL Gmail or intermediaries invoke to deliver notifications into this hook pipeline. Keep this URL protected with token validation and restricted network exposure.", "hasChildren": false @@ -35736,7 +40355,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Include Body", "help": "When true, fetch and include email body content for downstream mapping/agent processing. Keep false unless body text is required, because this increases payload size and sensitivity.", "hasChildren": false @@ -35748,7 +40369,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Label", "help": "Optional Gmail label filter limiting which labeled messages trigger hook events. Keep filters narrow to avoid flooding automations with unrelated inbox traffic.", "hasChildren": false @@ -35760,7 +40383,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Gmail Hook Max Body Bytes", "help": "Maximum Gmail payload bytes processed per event when includeBody is enabled. Keep conservative limits to reduce oversized message processing cost and risk.", "hasChildren": false @@ -35772,7 +40397,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Gmail Hook Model Override", "help": "Optional model override for Gmail-triggered runs when mailbox automations should use dedicated model behavior. Keep unset to inherit agent defaults unless mailbox tasks need specialization.", "hasChildren": false @@ -35784,7 +40411,10 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "label": "Gmail Hook Push Token", "help": "Shared secret token required on Gmail push hook callbacks before processing notifications. Use env substitution and rotate if callback endpoints are exposed externally.", "hasChildren": false @@ -35796,7 +40426,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Renew Interval (min)", "help": "Renewal cadence in minutes for Gmail watch subscriptions to prevent expiration. Set below provider expiration windows and monitor renew failures in logs.", "hasChildren": false @@ -35808,7 +40440,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Local Server", "help": "Local callback server settings block for directly receiving Gmail notifications without a separate ingress layer. Enable only when this process should terminate webhook traffic itself.", "hasChildren": true @@ -35820,7 +40454,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Server Bind Address", "help": "Bind address for the local Gmail callback HTTP server used when serving hooks directly. Keep loopback-only unless external ingress is intentionally required.", "hasChildren": false @@ -35832,7 +40468,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Gmail Hook Server Path", "help": "HTTP path on the local Gmail callback server where push notifications are accepted. Keep this consistent with subscription configuration to avoid dropped events.", "hasChildren": false @@ -35844,7 +40482,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Server Port", "help": "Port for the local Gmail callback HTTP server when serve mode is enabled. Use a dedicated port to avoid collisions with gateway/control interfaces.", "hasChildren": false @@ -35856,7 +40496,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Subscription", "help": "Pub/Sub subscription consumed by the gateway to receive Gmail change notifications from the configured topic. Keep subscription ownership clear so multiple consumers do not race unexpectedly.", "hasChildren": false @@ -35868,7 +40510,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Tailscale", "help": "Tailscale exposure configuration block for publishing Gmail callbacks through Serve/Funnel routes. Use private tailnet modes before enabling any public ingress path.", "hasChildren": true @@ -35880,7 +40524,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Tailscale Mode", "help": "Tailscale exposure mode for Gmail callbacks: \"off\", \"serve\", or \"funnel\". Use \"serve\" for private tailnet delivery and \"funnel\" only when public internet ingress is required.", "hasChildren": false @@ -35892,7 +40538,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Gmail Hook Tailscale Path", "help": "Path published by Tailscale Serve/Funnel for Gmail callback forwarding when enabled. Keep it aligned with Gmail webhook config so requests reach the expected handler.", "hasChildren": false @@ -35904,7 +40552,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Tailscale Target", "help": "Local service target forwarded by Tailscale Serve/Funnel (for example http://127.0.0.1:8787). Use explicit loopback targets to avoid ambiguous routing.", "hasChildren": false @@ -35916,7 +40566,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Thinking Override", "help": "Thinking effort override for Gmail-driven agent runs: \"off\", \"minimal\", \"low\", \"medium\", or \"high\". Keep modest defaults for routine inbox automations to control cost and latency.", "hasChildren": false @@ -35928,7 +40580,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Pub/Sub Topic", "help": "Google Pub/Sub topic name used by Gmail watch to publish change notifications for this account. Ensure the topic IAM grants Gmail publish access before enabling watches.", "hasChildren": false @@ -35940,7 +40594,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Internal Hooks", "help": "Internal hook runtime settings for bundled/custom event handlers loaded from module paths. Use this for trusted in-process automations and keep handler loading tightly scoped.", "hasChildren": true @@ -35952,7 +40608,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Internal Hooks Enabled", "help": "Enables processing for internal hook handlers and configured entries in the internal hook runtime. Keep disabled unless internal hook handlers are intentionally configured.", "hasChildren": false @@ -35964,7 +40622,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Internal Hook Entries", "help": "Configured internal hook entry records used to register concrete runtime handlers and metadata. Keep entries explicit and versioned so production behavior is auditable.", "hasChildren": true @@ -36025,7 +40685,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Internal Hook Handlers", "help": "List of internal event handlers mapping event names to modules and optional exports. Keep handler definitions explicit so event-to-code routing is auditable.", "hasChildren": true @@ -36047,7 +40709,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Internal Hook Event", "help": "Internal event name that triggers this handler module when emitted by the runtime. Use stable event naming conventions to avoid accidental overlap across handlers.", "hasChildren": false @@ -36059,7 +40723,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Internal Hook Export", "help": "Optional named export for the internal hook handler function when module default export is not used. Set this when one module ships multiple handler entrypoints.", "hasChildren": false @@ -36071,7 +40737,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Internal Hook Module", "help": "Safe relative module path for the internal hook handler implementation loaded at runtime. Keep module files in reviewed directories and avoid dynamic path composition.", "hasChildren": false @@ -36083,7 +40751,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Internal Hook Install Records", "help": "Install metadata for internal hook modules, including source and resolved artifacts for repeatable deployments. Use this as operational provenance and avoid manual drift edits.", "hasChildren": true @@ -36245,7 +40915,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Internal Hook Loader", "help": "Internal hook loader settings controlling where handler modules are discovered at startup. Use constrained load roots to reduce accidental module conflicts or shadowing.", "hasChildren": true @@ -36257,7 +40929,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Internal Hook Extra Directories", "help": "Additional directories searched for internal hook modules beyond default load paths. Keep this minimal and controlled to reduce accidental module shadowing.", "hasChildren": true @@ -36279,7 +40953,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mappings", "help": "Ordered mapping rules that match inbound hook requests and choose wake or agent actions with optional delivery routing. Use specific mappings first to avoid broad pattern rules capturing everything.", "hasChildren": true @@ -36301,7 +40977,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Action", "help": "Mapping action type: \"wake\" triggers agent wake flow, while \"agent\" sends directly to agent handling. Use \"agent\" for immediate execution and \"wake\" when heartbeat-driven processing is preferred.", "hasChildren": false @@ -36313,7 +40991,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Agent ID", "help": "Target agent ID for mapping execution when action routing should not use defaults. Use dedicated automation agents to isolate webhook behavior from interactive operator sessions.", "hasChildren": false @@ -36325,7 +41005,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Hook Mapping Allow Unsafe External Content", "help": "When true, mapping content may include less-sanitized external payload data in generated messages. Keep false by default and enable only for trusted sources with reviewed transform logic.", "hasChildren": false @@ -36337,7 +41019,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Delivery Channel", "help": "Delivery channel override for mapping outputs (for example \"last\", \"telegram\", \"discord\", \"slack\", \"signal\", \"imessage\", or \"msteams\"). Keep channel overrides explicit to avoid accidental cross-channel sends.", "hasChildren": false @@ -36349,7 +41033,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Deliver Reply", "help": "Controls whether mapping execution results are delivered back to a channel destination versus being processed silently. Disable delivery for background automations that should not post user-facing output.", "hasChildren": false @@ -36361,7 +41047,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping ID", "help": "Optional stable identifier for a hook mapping entry used for auditing, troubleshooting, and targeted updates. Use unique IDs so logs and config diffs can reference mappings unambiguously.", "hasChildren": false @@ -36373,7 +41061,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Match", "help": "Grouping object for mapping match predicates such as path and source before action routing is applied. Keep match criteria specific so unrelated webhook traffic does not trigger automations.", "hasChildren": true @@ -36385,7 +41075,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Hook Mapping Match Path", "help": "Path match condition for a hook mapping, usually compared against the inbound request path. Use this to split automation behavior by webhook endpoint path families.", "hasChildren": false @@ -36397,7 +41089,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Match Source", "help": "Source match condition for a hook mapping, typically set by trusted upstream metadata or adapter logic. Use stable source identifiers so routing remains deterministic across retries.", "hasChildren": false @@ -36409,7 +41103,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Message Template", "help": "Template for synthesizing structured mapping input into the final message content sent to the target action path. Keep templates deterministic so downstream parsing and behavior remain stable.", "hasChildren": false @@ -36421,7 +41117,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Hook Mapping Model Override", "help": "Optional model override for mapping-triggered runs when automation should use a different model than agent defaults. Use this sparingly so behavior remains predictable across mapping executions.", "hasChildren": false @@ -36433,7 +41131,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Name", "help": "Human-readable mapping display name used in diagnostics and operator-facing config UIs. Keep names concise and descriptive so routing intent is obvious during incident review.", "hasChildren": false @@ -36445,7 +41145,10 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["security", "storage"], + "tags": [ + "security", + "storage" + ], "label": "Hook Mapping Session Key", "help": "Explicit session key override for mapping-delivered messages to control thread continuity. Use stable scoped keys so repeated events correlate without leaking into unrelated conversations.", "hasChildren": false @@ -36457,7 +41160,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Text Template", "help": "Text-only fallback template used when rich payload rendering is not desired or not supported. Use this to provide a concise, consistent summary string for chat delivery surfaces.", "hasChildren": false @@ -36469,7 +41174,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Thinking Override", "help": "Optional thinking-effort override for mapping-triggered runs to tune latency versus reasoning depth. Keep low or minimal for high-volume hooks unless deeper reasoning is clearly required.", "hasChildren": false @@ -36481,7 +41188,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Hook Mapping Timeout (sec)", "help": "Maximum runtime allowed for mapping action execution before timeout handling applies. Use tighter limits for high-volume webhook sources to prevent queue pileups.", "hasChildren": false @@ -36493,7 +41202,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Delivery Destination", "help": "Destination identifier inside the selected channel when mapping replies should route to a fixed target. Verify provider-specific destination formats before enabling production mappings.", "hasChildren": false @@ -36505,7 +41216,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Transform", "help": "Transform configuration block defining module/export preprocessing before mapping action handling. Use transforms only from reviewed code paths and keep behavior deterministic for repeatable automation.", "hasChildren": true @@ -36517,7 +41230,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Transform Export", "help": "Named export to invoke from the transform module; defaults to module default export when omitted. Set this when one file hosts multiple transform handlers.", "hasChildren": false @@ -36529,7 +41244,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Transform Module", "help": "Relative transform module path loaded from hooks.transformsDir to rewrite incoming payloads before delivery. Keep modules local, reviewed, and free of path traversal patterns.", "hasChildren": false @@ -36541,7 +41258,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Wake Mode", "help": "Wake scheduling mode: \"now\" wakes immediately, while \"next-heartbeat\" defers until the next heartbeat cycle. Use deferred mode for lower-priority automations that can tolerate slight delay.", "hasChildren": false @@ -36553,7 +41272,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Hooks Max Body Bytes", "help": "Maximum accepted webhook payload size in bytes before the request is rejected. Keep this bounded to reduce abuse risk and protect memory usage under bursty integrations.", "hasChildren": false @@ -36565,7 +41286,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Hooks Endpoint Path", "help": "HTTP path used by the hooks endpoint (for example `/hooks`) on the gateway control server. Use a non-guessable path and combine it with token validation for defense in depth.", "hasChildren": false @@ -36577,7 +41300,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hooks Presets", "help": "Named hook preset bundles applied at load time to seed standard mappings and behavior defaults. Keep preset usage explicit so operators can audit which automations are active.", "hasChildren": true @@ -36599,7 +41324,10 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "label": "Hooks Auth Token", "help": "Shared bearer token checked by hooks ingress for request authentication before mappings run. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.", "hasChildren": false @@ -36611,7 +41339,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Hooks Transforms Directory", "help": "Base directory for hook transform modules referenced by mapping transform.module paths. Use a controlled repo directory so dynamic imports remain reviewable and predictable.", "hasChildren": false @@ -36623,7 +41353,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Logging", "help": "Logging behavior controls for severity, output destinations, formatting, and sensitive-data redaction. Keep levels and redaction strict enough for production while preserving useful diagnostics.", "hasChildren": true @@ -36635,7 +41367,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "Console Log Level", "help": "Console-specific log threshold: \"silent\", \"fatal\", \"error\", \"warn\", \"info\", \"debug\", or \"trace\" for terminal output control. Use this to keep local console quieter while retaining richer file logging if needed.", "hasChildren": false @@ -36647,7 +41381,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "Console Log Style", "help": "Console output format style: \"pretty\", \"compact\", or \"json\" based on operator and ingestion needs. Use json for machine parsing pipelines and pretty/compact for human-first terminal workflows.", "hasChildren": false @@ -36659,7 +41395,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "storage"], + "tags": [ + "observability", + "storage" + ], "label": "Log File Path", "help": "Optional file path for persisted log output in addition to or instead of console logging. Use a managed writable path and align retention/rotation with your operational policy.", "hasChildren": false @@ -36671,7 +41410,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "Log Level", "help": "Primary log level threshold for runtime logger output: \"silent\", \"fatal\", \"error\", \"warn\", \"info\", \"debug\", or \"trace\". Keep \"info\" or \"warn\" for production, and use debug/trace only during investigation.", "hasChildren": false @@ -36693,7 +41434,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "privacy"], + "tags": [ + "observability", + "privacy" + ], "label": "Custom Redaction Patterns", "help": "Additional custom redact regex patterns applied to log output before emission/storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.", "hasChildren": true @@ -36715,7 +41459,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "privacy"], + "tags": [ + "observability", + "privacy" + ], "label": "Sensitive Data Redaction Mode", "help": "Sensitive redaction mode: \"off\" disables built-in masking, while \"tools\" redacts sensitive tool/config payload fields. Keep \"tools\" in shared logs unless you have isolated secure log sinks.", "hasChildren": false @@ -36727,7 +41474,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Media", "help": "Top-level media behavior shared across providers and tools that handle inbound files. Keep defaults unless you need stable filenames for external processing pipelines or longer-lived inbound media retention.", "hasChildren": true @@ -36739,7 +41488,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Preserve Media Filenames", "help": "When enabled, uploaded media keeps its original filename instead of a generated temp-safe name. Turn this on when downstream automations depend on stable names, and leave off to reduce accidental filename leakage.", "hasChildren": false @@ -36751,7 +41502,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Media Retention TTL (hours)", "help": "Optional retention window in hours for persisted inbound media cleanup across the full media tree. Leave unset to preserve legacy behavior, or set values like 24 (1 day) or 168 (7 days) when you want automatic cleanup.", "hasChildren": false @@ -36763,7 +41516,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory", "help": "Memory backend configuration (global).", "hasChildren": true @@ -36775,7 +41530,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Memory Backend", "help": "Selects the global memory engine: \"builtin\" uses OpenClaw memory internals, while \"qmd\" uses the QMD sidecar pipeline. Keep \"builtin\" unless you intentionally operate QMD.", "hasChildren": false @@ -36787,7 +41544,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Memory Citations Mode", "help": "Controls citation visibility in replies: \"auto\" shows citations when useful, \"on\" always shows them, and \"off\" hides them. Keep \"auto\" for a balanced signal-to-noise default.", "hasChildren": false @@ -36809,7 +41568,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD Binary", "help": "Sets the executable path for the `qmd` binary used by the QMD backend (default: resolved from PATH). Use an explicit absolute path when multiple qmd installs exist or PATH differs across environments.", "hasChildren": false @@ -36821,7 +41582,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD Include Default Memory", "help": "Automatically indexes default memory files (MEMORY.md and memory/**/*.md) into QMD collections. Keep enabled unless you want indexing controlled only through explicit custom paths.", "hasChildren": false @@ -36843,7 +41606,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "QMD Max Injected Chars", "help": "Caps how much QMD text can be injected into one turn across all hits. Use lower values to control prompt bloat and latency; raise only when context is consistently truncated.", "hasChildren": false @@ -36855,7 +41621,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "QMD Max Results", "help": "Limits how many QMD hits are returned into the agent loop for each recall request (default: 6). Increase for broader recall context, or lower to keep prompts tighter and faster.", "hasChildren": false @@ -36867,7 +41636,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "QMD Max Snippet Chars", "help": "Caps per-result snippet length extracted from QMD hits in characters (default: 700). Lower this when prompts bloat quickly, and raise only if answers consistently miss key details.", "hasChildren": false @@ -36879,7 +41651,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "QMD Search Timeout (ms)", "help": "Sets per-query QMD search timeout in milliseconds (default: 4000). Increase for larger indexes or slower environments, and lower to keep request latency bounded.", "hasChildren": false @@ -36891,7 +41666,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD MCPorter", "help": "Routes QMD work through mcporter (MCP runtime) instead of spawning `qmd` for each call. Use this when cold starts are expensive on large models; keep direct process mode for simpler local setups.", "hasChildren": true @@ -36903,7 +41680,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD MCPorter Enabled", "help": "Routes QMD through an mcporter daemon instead of spawning qmd per request, reducing cold-start overhead for larger models. Keep disabled unless mcporter is installed and configured.", "hasChildren": false @@ -36915,7 +41694,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD MCPorter Server Name", "help": "Names the mcporter server target used for QMD calls (default: qmd). Change only when your mcporter setup uses a custom server name for qmd mcp keep-alive.", "hasChildren": false @@ -36927,7 +41708,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD MCPorter Start Daemon", "help": "Automatically starts the mcporter daemon when mcporter-backed QMD mode is enabled (default: true). Keep enabled unless process lifecycle is managed externally by your service supervisor.", "hasChildren": false @@ -36939,7 +41722,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD Extra Paths", "help": "Adds custom directories or files to include in QMD indexing, each with an optional name and glob pattern. Use this for project-specific knowledge locations that are outside default memory paths.", "hasChildren": true @@ -36991,7 +41776,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD Surface Scope", "help": "Defines which sessions/channels are eligible for QMD recall using session.sendPolicy-style rules. Keep default direct-only scope unless you intentionally want cross-chat memory sharing.", "hasChildren": true @@ -37093,7 +41880,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD Search Mode", "help": "Selects the QMD retrieval path: \"query\" uses standard query flow, \"search\" uses search-oriented retrieval, and \"vsearch\" emphasizes vector retrieval. Keep default unless tuning relevance quality.", "hasChildren": false @@ -37115,7 +41904,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD Session Indexing", "help": "Indexes session transcripts into QMD so recall can include prior conversation content (experimental, default: false). Enable only when transcript memory is required and you accept larger index churn.", "hasChildren": false @@ -37127,7 +41918,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD Session Export Directory", "help": "Overrides where sanitized session exports are written before QMD indexing. Use this when default state storage is constrained or when exports must land on a managed volume.", "hasChildren": false @@ -37139,7 +41932,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD Session Retention (days)", "help": "Defines how long exported session files are kept before automatic pruning, in days (default: unlimited). Set a finite value for storage hygiene or compliance retention policies.", "hasChildren": false @@ -37161,7 +41956,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "QMD Command Timeout (ms)", "help": "Sets timeout for QMD maintenance commands such as collection list/add in milliseconds (default: 30000). Increase when running on slower disks or remote filesystems that delay command completion.", "hasChildren": false @@ -37173,7 +41971,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "QMD Update Debounce (ms)", "help": "Sets the minimum delay between consecutive QMD refresh attempts in milliseconds (default: 15000). Increase this if frequent file changes cause update thrash or unnecessary background load.", "hasChildren": false @@ -37185,7 +41986,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "QMD Embed Interval", "help": "Sets how often QMD recomputes embeddings (duration string, default: 60m; set 0 to disable periodic embeds). Lower intervals improve freshness but increase embedding workload and cost.", "hasChildren": false @@ -37197,7 +42001,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "QMD Embed Timeout (ms)", "help": "Sets maximum runtime for each `qmd embed` cycle in milliseconds (default: 120000). Increase for heavier embedding workloads or slower hardware, and lower to fail fast under tight SLAs.", "hasChildren": false @@ -37209,7 +42016,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "QMD Update Interval", "help": "Sets how often QMD refreshes indexes from source content (duration string, default: 5m). Shorter intervals improve freshness but increase background CPU and I/O.", "hasChildren": false @@ -37221,7 +42031,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD Update on Startup", "help": "Runs an initial QMD update once during gateway startup (default: true). Keep enabled so recall starts from a fresh baseline; disable only when startup speed is more important than immediate freshness.", "hasChildren": false @@ -37233,7 +42045,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "QMD Update Timeout (ms)", "help": "Sets maximum runtime for each `qmd update` cycle in milliseconds (default: 120000). Raise this for larger collections; lower it when you want quicker failure detection in automation.", "hasChildren": false @@ -37245,7 +42060,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD Wait for Boot Sync", "help": "Blocks startup completion until the initial boot-time QMD sync finishes (default: false). Enable when you need fully up-to-date recall before serving traffic, and keep off for faster boot.", "hasChildren": false @@ -37257,7 +42074,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Messages", "help": "Message formatting, acknowledgment, queueing, debounce, and status reaction behavior for inbound/outbound chat flows. Use this section when channel responsiveness or message UX needs adjustment.", "hasChildren": true @@ -37269,7 +42088,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Ack Reaction Emoji", "help": "Emoji reaction used to acknowledge inbound messages (empty disables).", "hasChildren": false @@ -37279,10 +42100,19 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["group-mentions", "group-all", "direct", "all", "off", "none"], + "enumValues": [ + "group-mentions", + "group-all", + "direct", + "all", + "off", + "none" + ], "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Ack Reaction Scope", "help": "When to send ack reactions (\"group-mentions\", \"group-all\", \"direct\", \"all\", \"off\", \"none\"). \"off\"/\"none\" disables ack reactions entirely.", "hasChildren": false @@ -37294,7 +42124,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Group Chat Rules", "help": "Group-message handling controls including mention triggers and history window sizing. Keep mention patterns narrow so group channels do not trigger on every message.", "hasChildren": true @@ -37306,7 +42138,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Group History Limit", "help": "Maximum number of prior group messages loaded as context per turn for group sessions. Use higher values for richer continuity, or lower values for faster and cheaper responses.", "hasChildren": false @@ -37318,9 +42152,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Group Mention Patterns", - "help": "Regex-like patterns used to detect explicit mentions/trigger phrases in group chats. Use precise patterns to reduce false positives in high-volume channels.", + "help": "Safe case-insensitive regex patterns used to detect explicit mentions/trigger phrases in group chats. Use precise patterns to reduce false positives in high-volume channels; invalid or unsafe nested-repetition patterns are ignored.", "hasChildren": true }, { @@ -37340,7 +42176,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Inbound Debounce", "help": "Direct inbound debounce settings used before queue/turn processing starts. Configure this for provider-specific rapid message bursts from the same sender.", "hasChildren": true @@ -37352,7 +42190,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Inbound Debounce by Channel (ms)", "help": "Per-channel inbound debounce overrides keyed by provider id in milliseconds. Use this where some providers send message fragments more aggressively than others.", "hasChildren": true @@ -37374,7 +42214,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Inbound Message Debounce (ms)", "help": "Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).", "hasChildren": false @@ -37386,7 +42228,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Inbound Message Prefix", "help": "Prefix text prepended to inbound user messages before they are handed to the agent runtime. Use this sparingly for channel context markers and keep it stable across sessions.", "hasChildren": false @@ -37398,7 +42242,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Inbound Queue", "help": "Inbound message queue strategy used to buffer bursts before processing turns. Tune this for busy channels where sequential processing or batching behavior matters.", "hasChildren": true @@ -37410,7 +42256,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Queue Mode by Channel", "help": "Per-channel queue mode overrides keyed by provider id (for example telegram, discord, slack). Use this when one channel’s traffic pattern needs different queue behavior than global defaults.", "hasChildren": true @@ -37522,7 +42370,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Queue Capacity", "help": "Maximum number of queued inbound items retained before drop policy applies. Keep caps bounded in noisy channels so memory usage remains predictable.", "hasChildren": false @@ -37534,7 +42384,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Queue Debounce (ms)", "help": "Global queue debounce window in milliseconds before processing buffered inbound messages. Use higher values to coalesce rapid bursts, or lower values for reduced response latency.", "hasChildren": false @@ -37546,7 +42398,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Queue Debounce by Channel (ms)", "help": "Per-channel debounce overrides for queue behavior keyed by provider id. Use this to tune burst handling independently for chat surfaces with different pacing.", "hasChildren": true @@ -37568,7 +42422,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Queue Drop Strategy", "help": "Drop strategy when queue cap is exceeded: \"old\", \"new\", or \"summarize\". Use summarize when preserving intent matters, or old/new when deterministic dropping is preferred.", "hasChildren": false @@ -37580,7 +42436,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Queue Mode", "help": "Queue behavior mode: \"steer\", \"followup\", \"collect\", \"steer-backlog\", \"steer+backlog\", \"queue\", or \"interrupt\". Keep conservative modes unless you intentionally need aggressive interruption/backlog semantics.", "hasChildren": false @@ -37592,7 +42450,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Remove Ack Reaction After Reply", "help": "Removes the acknowledgment reaction after final reply delivery when enabled. Keep enabled for cleaner UX in channels where persistent ack reactions create clutter.", "hasChildren": false @@ -37604,7 +42464,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Outbound Response Prefix", "help": "Prefix text prepended to outbound assistant replies before sending to channels. Use for lightweight branding/context tags and avoid long prefixes that reduce content density.", "hasChildren": false @@ -37616,7 +42478,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Status Reactions", "help": "Lifecycle status reactions that update the emoji on the trigger message as the agent progresses (queued → thinking → tool → done/error).", "hasChildren": true @@ -37628,7 +42492,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Status Reaction Emojis", "help": "Override default status reaction emojis. Keys: thinking, compacting, tool, coding, web, done, error, stallSoft, stallHard. Must be valid Telegram reaction emojis.", "hasChildren": true @@ -37730,7 +42596,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable Status Reactions", "help": "Enable lifecycle status reactions for Telegram. When enabled, the ack reaction becomes the initial 'queued' state and progresses through thinking, tool, done/error automatically. Default: false.", "hasChildren": false @@ -37742,7 +42610,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Status Reaction Timing", "help": "Override default timing. Keys: debounceMs (700), stallSoftMs (25000), stallHardMs (60000), doneHoldMs (1500), errorHoldMs (2500).", "hasChildren": true @@ -37804,7 +42674,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Suppress Tool Error Warnings", "help": "When true, suppress ⚠️ tool-error warnings from being shown to the user. The agent already sees errors in context and can retry. Default: false.", "hasChildren": false @@ -37816,7 +42688,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Message Text-to-Speech", "help": "Text-to-speech policy for reading agent replies aloud on supported voice or audio surfaces. Keep disabled unless voice playback is part of your operator/user workflow.", "hasChildren": true @@ -37826,7 +42700,12 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["off", "always", "inbound", "tagged"], + "enumValues": [ + "off", + "always", + "inbound", + "tagged" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -37955,11 +42834,18 @@ { "path": "messages.tts.elevenlabs.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "media", "security"], + "tags": [ + "auth", + "media", + "security" + ], "hasChildren": true }, { @@ -37997,7 +42883,11 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["auto", "on", "off"], + "enumValues": [ + "auto", + "on", + "off" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -38138,7 +43028,10 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["final", "all"], + "enumValues": [ + "final", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -38247,11 +43140,18 @@ { "path": "messages.tts.openai.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "media", "security"], + "tags": [ + "auth", + "media", + "security" + ], "hasChildren": true }, { @@ -38349,7 +43249,11 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["elevenlabs", "openai", "edge"], + "enumValues": [ + "elevenlabs", + "openai", + "edge" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -38382,7 +43286,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Metadata", "help": "Metadata fields automatically maintained by OpenClaw to record write/version history for this config file. Keep these values system-managed and avoid manual edits unless debugging migration history.", "hasChildren": true @@ -38394,7 +43300,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Config Last Touched At", "help": "ISO timestamp of the last config write (auto-set).", "hasChildren": false @@ -38406,7 +43314,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Config Last Touched Version", "help": "Auto-set when OpenClaw writes the config.", "hasChildren": false @@ -38418,7 +43328,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Models", "help": "Model catalog root for provider definitions, merge/replace behavior, and optional Bedrock discovery integration. Keep provider definitions explicit and validated before relying on production failover paths.", "hasChildren": true @@ -38430,7 +43342,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Bedrock Model Discovery", "help": "Automatic AWS Bedrock model discovery settings used to synthesize provider model entries from account visibility. Keep discovery scoped and refresh intervals conservative to reduce API churn.", "hasChildren": true @@ -38442,7 +43356,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Bedrock Default Context Window", "help": "Fallback context-window value applied to discovered models when provider metadata lacks explicit limits. Use realistic defaults to avoid oversized prompts that exceed true provider constraints.", "hasChildren": false @@ -38454,7 +43370,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["auth", "models", "performance", "security"], + "tags": [ + "auth", + "models", + "performance", + "security" + ], "label": "Bedrock Default Max Tokens", "help": "Fallback max-token value applied to discovered models without explicit output token limits. Use conservative defaults to reduce truncation surprises and unexpected token spend.", "hasChildren": false @@ -38466,7 +43387,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Bedrock Discovery Enabled", "help": "Enables periodic Bedrock model discovery and catalog refresh for Bedrock-backed providers. Keep disabled unless Bedrock is actively used and IAM permissions are correctly configured.", "hasChildren": false @@ -38478,7 +43401,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Bedrock Discovery Provider Filter", "help": "Optional provider allowlist filter for Bedrock discovery so only selected providers are refreshed. Use this to limit discovery scope in multi-provider environments.", "hasChildren": true @@ -38500,7 +43425,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models", "performance"], + "tags": [ + "models", + "performance" + ], "label": "Bedrock Discovery Refresh Interval (s)", "help": "Refresh cadence for Bedrock discovery polling in seconds to detect newly available models over time. Use longer intervals in production to reduce API cost and control-plane noise.", "hasChildren": false @@ -38512,7 +43440,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Bedrock Discovery Region", "help": "AWS region used for Bedrock discovery calls when discovery is enabled for your deployment. Use the region where your Bedrock models are provisioned to avoid empty discovery results.", "hasChildren": false @@ -38524,7 +43454,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Model Catalog Mode", "help": "Controls provider catalog behavior: \"merge\" keeps built-ins and overlays your custom providers, while \"replace\" uses only your configured providers. In \"merge\", matching provider IDs preserve non-empty agent models.json baseUrl values, while apiKey values are preserved only when the provider is not SecretRef-managed in current config/auth-profile context; SecretRef-managed providers refresh apiKey from current source markers, and matching model contextWindow/maxTokens use the higher value between explicit and implicit entries.", "hasChildren": false @@ -38536,7 +43468,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Model Providers", "help": "Provider map keyed by provider ID containing connection/auth settings and concrete model definitions. Use stable provider keys so references from agents and tooling remain portable across environments.", "hasChildren": true @@ -38568,7 +43502,9 @@ ], "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Model Provider API Adapter", "help": "Provider API adapter selection controlling request/response compatibility handling for model calls. Use the adapter that matches your upstream provider protocol to avoid feature mismatch.", "hasChildren": false @@ -38576,11 +43512,18 @@ { "path": "models.providers.*.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "models", "security"], + "tags": [ + "auth", + "models", + "security" + ], "label": "Model Provider API Key", "help": "Provider credential used for API-key based authentication when the provider requires direct key auth. Use secret/env substitution and avoid storing real keys in committed config files.", "hasChildren": true @@ -38622,7 +43565,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Model Provider Auth Mode", "help": "Selects provider auth style: \"api-key\" for API key auth, \"token\" for bearer token auth, \"oauth\" for OAuth credentials, and \"aws-sdk\" for AWS credential resolution. Match this to your provider requirements.", "hasChildren": false @@ -38634,7 +43579,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Model Provider Authorization Header", "help": "When true, credentials are sent via the HTTP Authorization header even if alternate auth is possible. Use this only when your provider or proxy explicitly requires Authorization forwarding.", "hasChildren": false @@ -38646,7 +43593,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Model Provider Base URL", "help": "Base URL for the provider endpoint used to serve model requests for that provider entry. Use HTTPS endpoints and keep URLs environment-specific through config templating where needed.", "hasChildren": false @@ -38658,7 +43607,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Model Provider Headers", "help": "Static HTTP headers merged into provider requests for tenant routing, proxy auth, or custom gateway requirements. Use this sparingly and keep sensitive header values in secrets.", "hasChildren": true @@ -38666,11 +43617,17 @@ { "path": "models.providers.*.headers.*", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["models", "security"], + "tags": [ + "models", + "security" + ], "hasChildren": true }, { @@ -38710,7 +43667,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Model Provider Inject num_ctx (OpenAI Compat)", "help": "Controls whether OpenClaw injects `options.num_ctx` for Ollama providers configured with the OpenAI-compatible adapter (`openai-completions`). Default is true. Set false only if your proxy/upstream rejects unknown `options` payload fields.", "hasChildren": false @@ -38722,7 +43681,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Model Provider Model List", "help": "Declared model list for a provider including identifiers, metadata, and optional compatibility/cost hints. Keep IDs exact to provider catalog values so selection and fallback resolve correctly.", "hasChildren": true @@ -39044,7 +44005,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Node Host", "help": "Node host controls for features exposed from this gateway node to other nodes or clients. Keep defaults unless you intentionally proxy local capabilities across your node network.", "hasChildren": true @@ -39056,7 +44019,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Node Browser Proxy", "help": "Groups browser-proxy settings for exposing local browser control through node routing. Enable only when remote node workflows need your local browser profiles.", "hasChildren": true @@ -39068,7 +44033,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "network", "storage"], + "tags": [ + "access", + "network", + "storage" + ], "label": "Node Browser Proxy Allowed Profiles", "help": "Optional allowlist of browser profile names exposed through node proxy routing. Leave empty to expose all configured profiles, or use a tight list to enforce least-privilege profile access.", "hasChildren": true @@ -39090,7 +44059,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Node Browser Proxy Enabled", "help": "Expose the local browser control server through node proxy routing so remote clients can use this host's browser capabilities. Keep disabled unless remote automation explicitly depends on it.", "hasChildren": false @@ -39102,7 +44073,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugins", "help": "Plugin system controls for enabling extensions, constraining load scope, configuring entries, and tracking installs. Keep plugin policy explicit and least-privilege in production environments.", "hasChildren": true @@ -39114,7 +44087,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Plugin Allowlist", "help": "Optional allowlist of plugin IDs; when set, only listed plugins are eligible to load. Use this to enforce approved extension inventories in controlled environments.", "hasChildren": true @@ -39136,7 +44111,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Plugin Denylist", "help": "Optional denylist of plugin IDs that are blocked even if allowlists or paths include them. Use deny rules for emergency rollback and hard blocks on risky plugins.", "hasChildren": true @@ -39158,7 +44135,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable Plugins", "help": "Enable or disable plugin/extension loading globally during startup and config reload (default: true). Keep enabled only when extension capabilities are required by your deployment.", "hasChildren": false @@ -39170,7 +44149,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Entries", "help": "Per-plugin settings keyed by plugin ID including enablement and plugin-specific runtime configuration payloads. Use this for scoped plugin tuning without changing global loader policy.", "hasChildren": true @@ -39192,7 +44173,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Config", "help": "Plugin-defined configuration payload interpreted by that plugin's own schema and validation rules. Use only documented fields from the plugin to prevent ignored or invalid settings.", "hasChildren": true @@ -39213,7 +44196,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Enabled", "help": "Per-plugin enablement override for a specific entry, applied on top of global plugin policy (restart required). Use this to stage plugin rollout gradually across environments.", "hasChildren": false @@ -39225,7 +44210,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -39237,7 +44224,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -39249,7 +44238,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACPX Runtime", "help": "ACP runtime backend powered by acpx with configurable command path and version policy. (plugin: acpx)", "hasChildren": true @@ -39261,7 +44252,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACPX Runtime Config", "help": "Plugin-defined config payload for acpx.", "hasChildren": true @@ -39273,7 +44266,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "acpx Command", "help": "Optional path/command override for acpx (for example /home/user/repos/acpx/dist/cli.js). Leave unset to use plugin-local bundled acpx.", "hasChildren": false @@ -39285,7 +44280,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default Working Directory", "help": "Default cwd for ACP session operations when not set per session.", "hasChildren": false @@ -39297,7 +44294,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Expected acpx Version", "help": "Exact version to enforce (for example 0.1.16) or \"any\" to skip strict version matching.", "hasChildren": false @@ -39309,7 +44308,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "MCP Servers", "help": "Named MCP server definitions to inject into ACPX-backed session bootstrap. Each entry needs a command and can include args and env.", "hasChildren": true @@ -39379,10 +44380,15 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["deny", "fail"], + "enumValues": [ + "deny", + "fail" + ], "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Non-Interactive Permission Policy", "help": "acpx policy when interactive permission prompts are unavailable.", "hasChildren": false @@ -39392,10 +44398,16 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["approve-all", "approve-reads", "deny-all"], + "enumValues": [ + "approve-all", + "approve-reads", + "deny-all" + ], "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Permission Mode", "help": "Default acpx permission policy for runtime prompts.", "hasChildren": false @@ -39407,7 +44419,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "advanced"], + "tags": [ + "access", + "advanced" + ], "label": "Queue Owner TTL Seconds", "help": "Idle queue-owner TTL for acpx prompt turns. Keep this short in OpenClaw to avoid delayed completion after each turn.", "hasChildren": false @@ -39419,7 +44434,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Strict Windows cmd Wrapper", "help": "Enabled by default. On Windows, reject unresolved .cmd/.bat wrappers instead of shell fallback. Disable only for compatibility with non-standard wrappers.", "hasChildren": false @@ -39431,7 +44448,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "performance"], + "tags": [ + "advanced", + "performance" + ], "label": "Prompt Timeout Seconds", "help": "Optional acpx timeout for each runtime turn.", "hasChildren": false @@ -39443,7 +44463,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable ACPX Runtime", "hasChildren": false }, @@ -39454,7 +44476,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -39466,7 +44490,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -39478,7 +44504,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/bluebubbles", "help": "OpenClaw BlueBubbles channel plugin (plugin: bluebubbles)", "hasChildren": true @@ -39490,7 +44518,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/bluebubbles Config", "help": "Plugin-defined config payload for bluebubbles.", "hasChildren": false @@ -39502,7 +44532,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/bluebubbles", "hasChildren": false }, @@ -39513,7 +44545,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -39525,7 +44559,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -39537,7 +44573,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/copilot-proxy", "help": "OpenClaw Copilot Proxy provider plugin (plugin: copilot-proxy)", "hasChildren": true @@ -39549,7 +44587,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/copilot-proxy Config", "help": "Plugin-defined config payload for copilot-proxy.", "hasChildren": false @@ -39561,7 +44601,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/copilot-proxy", "hasChildren": false }, @@ -39572,7 +44614,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -39584,7 +44628,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -39596,7 +44642,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Device Pairing", "help": "Generate setup codes and approve device pairing requests. (plugin: device-pair)", "hasChildren": true @@ -39608,7 +44656,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Device Pairing Config", "help": "Plugin-defined config payload for device-pair.", "hasChildren": true @@ -39620,7 +44670,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gateway URL", "help": "Public WebSocket URL used for /pair setup codes (ws/wss or http/https).", "hasChildren": false @@ -39632,7 +44684,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable Device Pairing", "hasChildren": false }, @@ -39643,7 +44697,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -39655,7 +44711,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -39667,7 +44725,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "@openclaw/diagnostics-otel", "help": "OpenClaw diagnostics OpenTelemetry exporter (plugin: diagnostics-otel)", "hasChildren": true @@ -39679,7 +44739,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "@openclaw/diagnostics-otel Config", "help": "Plugin-defined config payload for diagnostics-otel.", "hasChildren": false @@ -39691,7 +44753,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "Enable @openclaw/diagnostics-otel", "hasChildren": false }, @@ -39702,7 +44766,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -39714,7 +44780,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -39726,7 +44794,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Diffs", "help": "Read-only diff viewer and file renderer for agents. (plugin: diffs)", "hasChildren": true @@ -39738,7 +44808,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Diffs Config", "help": "Plugin-defined config payload for diffs.", "hasChildren": true @@ -39761,7 +44833,9 @@ "defaultValue": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default Background Highlights", "help": "Show added/removed background highlights by default.", "hasChildren": false @@ -39771,11 +44845,17 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["bars", "classic", "none"], + "enumValues": [ + "bars", + "classic", + "none" + ], "defaultValue": "bars", "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Diff Indicator Style", "help": "Choose added/removed indicators style.", "hasChildren": false @@ -39785,11 +44865,16 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["png", "pdf"], + "enumValues": [ + "png", + "pdf" + ], "defaultValue": "png", "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Default File Format", "help": "Rendered file format for file mode (PNG or PDF).", "hasChildren": false @@ -39802,7 +44887,10 @@ "defaultValue": 960, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "Default File Max Width", "help": "Maximum file render width in CSS pixels.", "hasChildren": false @@ -39812,11 +44900,17 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["standard", "hq", "print"], + "enumValues": [ + "standard", + "hq", + "print" + ], "defaultValue": "standard", "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Default File Quality", "help": "Quality preset for PNG/PDF rendering.", "hasChildren": false @@ -39829,7 +44923,9 @@ "defaultValue": 2, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Default File Scale", "help": "Device scale factor used while rendering file artifacts.", "hasChildren": false @@ -39842,7 +44938,9 @@ "defaultValue": "Fira Code", "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default Font", "help": "Preferred font family name for diff content and headers.", "hasChildren": false @@ -39855,7 +44953,9 @@ "defaultValue": 15, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default Font Size", "help": "Base diff font size in pixels.", "hasChildren": false @@ -39865,7 +44965,10 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["png", "pdf"], + "enumValues": [ + "png", + "pdf" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -39876,7 +44979,10 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["png", "pdf"], + "enumValues": [ + "png", + "pdf" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -39897,7 +45003,11 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["standard", "hq", "print"], + "enumValues": [ + "standard", + "hq", + "print" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -39918,11 +45028,16 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["unified", "split"], + "enumValues": [ + "unified", + "split" + ], "defaultValue": "unified", "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default Layout", "help": "Initial diff layout shown in the viewer.", "hasChildren": false @@ -39935,7 +45050,9 @@ "defaultValue": 1.6, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default Line Spacing", "help": "Line-height multiplier applied to diff rows.", "hasChildren": false @@ -39945,11 +45062,18 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["view", "image", "file", "both"], + "enumValues": [ + "view", + "image", + "file", + "both" + ], "defaultValue": "both", "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default Output Mode", "help": "Tool default when mode is omitted. Use view for canvas/gateway viewer, file for PNG/PDF, or both.", "hasChildren": false @@ -39962,7 +45086,9 @@ "defaultValue": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Show Line Numbers", "help": "Show line numbers by default.", "hasChildren": false @@ -39972,11 +45098,16 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["light", "dark"], + "enumValues": [ + "light", + "dark" + ], "defaultValue": "dark", "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default Theme", "help": "Initial viewer theme.", "hasChildren": false @@ -39989,7 +45120,9 @@ "defaultValue": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default Word Wrap", "help": "Wrap long lines by default.", "hasChildren": false @@ -40012,7 +45145,9 @@ "defaultValue": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Remote Viewer", "help": "Allow non-loopback access to diff viewer URLs when the token path is known.", "hasChildren": false @@ -40024,7 +45159,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable Diffs", "hasChildren": false }, @@ -40035,7 +45172,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -40047,7 +45186,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -40059,7 +45200,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/discord", "help": "OpenClaw Discord channel plugin (plugin: discord)", "hasChildren": true @@ -40071,7 +45214,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/discord Config", "help": "Plugin-defined config payload for discord.", "hasChildren": false @@ -40083,7 +45228,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/discord", "hasChildren": false }, @@ -40094,7 +45241,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -40106,7 +45255,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -40118,7 +45269,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/feishu", "help": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng) (plugin: feishu)", "hasChildren": true @@ -40130,7 +45283,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/feishu Config", "help": "Plugin-defined config payload for feishu.", "hasChildren": false @@ -40142,7 +45297,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/feishu", "hasChildren": false }, @@ -40153,7 +45310,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -40165,7 +45324,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -40177,7 +45338,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/google-gemini-cli-auth", "help": "OpenClaw Gemini CLI OAuth provider plugin (plugin: google-gemini-cli-auth)", "hasChildren": true @@ -40189,7 +45352,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/google-gemini-cli-auth Config", "help": "Plugin-defined config payload for google-gemini-cli-auth.", "hasChildren": false @@ -40201,7 +45366,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/google-gemini-cli-auth", "hasChildren": false }, @@ -40212,7 +45379,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -40224,7 +45393,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -40236,7 +45407,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/googlechat", "help": "OpenClaw Google Chat channel plugin (plugin: googlechat)", "hasChildren": true @@ -40248,7 +45421,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/googlechat Config", "help": "Plugin-defined config payload for googlechat.", "hasChildren": false @@ -40260,7 +45435,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/googlechat", "hasChildren": false }, @@ -40271,7 +45448,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -40283,7 +45462,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -40295,7 +45476,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/imessage", "help": "OpenClaw iMessage channel plugin (plugin: imessage)", "hasChildren": true @@ -40307,7 +45490,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/imessage Config", "help": "Plugin-defined config payload for imessage.", "hasChildren": false @@ -40319,7 +45504,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/imessage", "hasChildren": false }, @@ -40330,7 +45517,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -40342,7 +45531,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -40354,7 +45545,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/irc", "help": "OpenClaw IRC channel plugin (plugin: irc)", "hasChildren": true @@ -40366,7 +45559,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/irc Config", "help": "Plugin-defined config payload for irc.", "hasChildren": false @@ -40378,7 +45573,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/irc", "hasChildren": false }, @@ -40389,7 +45586,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -40401,7 +45600,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -40413,7 +45614,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/line", "help": "OpenClaw LINE channel plugin (plugin: line)", "hasChildren": true @@ -40425,7 +45628,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/line Config", "help": "Plugin-defined config payload for line.", "hasChildren": false @@ -40437,7 +45642,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/line", "hasChildren": false }, @@ -40448,7 +45655,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -40460,7 +45669,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -40472,7 +45683,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "LLM Task", "help": "Generic JSON-only LLM tool for structured tasks callable from workflows. (plugin: llm-task)", "hasChildren": true @@ -40484,7 +45697,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "LLM Task Config", "help": "Plugin-defined config payload for llm-task.", "hasChildren": true @@ -40566,7 +45781,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable LLM Task", "hasChildren": false }, @@ -40577,7 +45794,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -40589,7 +45808,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -40601,7 +45822,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Lobster", "help": "Typed workflow tool with resumable approvals. (plugin: lobster)", "hasChildren": true @@ -40613,7 +45836,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Lobster Config", "help": "Plugin-defined config payload for lobster.", "hasChildren": false @@ -40625,7 +45850,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable Lobster", "hasChildren": false }, @@ -40636,7 +45863,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -40648,7 +45877,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -40660,7 +45891,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/matrix", "help": "OpenClaw Matrix channel plugin (plugin: matrix)", "hasChildren": true @@ -40672,7 +45905,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/matrix Config", "help": "Plugin-defined config payload for matrix.", "hasChildren": false @@ -40684,7 +45919,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/matrix", "hasChildren": false }, @@ -40695,7 +45932,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -40707,7 +45946,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -40719,7 +45960,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/mattermost", "help": "OpenClaw Mattermost channel plugin (plugin: mattermost)", "hasChildren": true @@ -40731,7 +45974,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/mattermost Config", "help": "Plugin-defined config payload for mattermost.", "hasChildren": false @@ -40743,7 +45988,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/mattermost", "hasChildren": false }, @@ -40754,7 +46001,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -40766,7 +46015,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -40778,7 +46029,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/memory-core", "help": "OpenClaw core memory search plugin (plugin: memory-core)", "hasChildren": true @@ -40790,7 +46043,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/memory-core Config", "help": "Plugin-defined config payload for memory-core.", "hasChildren": false @@ -40802,7 +46057,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/memory-core", "hasChildren": false }, @@ -40813,7 +46070,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -40825,7 +46084,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -40837,7 +46098,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "@openclaw/memory-lancedb", "help": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture (plugin: memory-lancedb)", "hasChildren": true @@ -40849,7 +46112,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "@openclaw/memory-lancedb Config", "help": "Plugin-defined config payload for memory-lancedb.", "hasChildren": true @@ -40861,7 +46126,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Auto-Capture", "help": "Automatically capture important information from conversations", "hasChildren": false @@ -40873,7 +46140,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Auto-Recall", "help": "Automatically inject relevant memories into context", "hasChildren": false @@ -40885,7 +46154,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "performance", "storage"], + "tags": [ + "advanced", + "performance", + "storage" + ], "label": "Capture Max Chars", "help": "Maximum message length eligible for auto-capture", "hasChildren": false @@ -40897,7 +46170,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "storage"], + "tags": [ + "advanced", + "storage" + ], "label": "Database Path", "hasChildren": false }, @@ -40918,7 +46194,11 @@ "required": true, "deprecated": false, "sensitive": true, - "tags": ["auth", "security", "storage"], + "tags": [ + "auth", + "security", + "storage" + ], "label": "OpenAI API Key", "help": "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})", "hasChildren": false @@ -40930,7 +46210,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "storage"], + "tags": [ + "advanced", + "storage" + ], "label": "Base URL", "help": "Base URL for compatible providers (e.g. http://localhost:11434/v1)", "hasChildren": false @@ -40942,7 +46225,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "storage"], + "tags": [ + "advanced", + "storage" + ], "label": "Dimensions", "help": "Vector dimensions for custom models (required for non-standard models)", "hasChildren": false @@ -40954,7 +46240,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models", "storage"], + "tags": [ + "models", + "storage" + ], "label": "Embedding Model", "help": "OpenAI embedding model to use", "hasChildren": false @@ -40966,7 +46255,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Enable @openclaw/memory-lancedb", "hasChildren": false }, @@ -40977,7 +46268,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -40989,7 +46282,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41001,7 +46296,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "@openclaw/minimax-portal-auth", "help": "OpenClaw MiniMax Portal OAuth provider plugin (plugin: minimax-portal-auth)", "hasChildren": true @@ -41013,7 +46310,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "@openclaw/minimax-portal-auth Config", "help": "Plugin-defined config payload for minimax-portal-auth.", "hasChildren": false @@ -41025,7 +46324,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Enable @openclaw/minimax-portal-auth", "hasChildren": false }, @@ -41036,7 +46337,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41048,7 +46351,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41060,7 +46365,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/msteams", "help": "OpenClaw Microsoft Teams channel plugin (plugin: msteams)", "hasChildren": true @@ -41072,7 +46379,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/msteams Config", "help": "Plugin-defined config payload for msteams.", "hasChildren": false @@ -41084,7 +46393,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/msteams", "hasChildren": false }, @@ -41095,7 +46406,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41107,7 +46420,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41119,7 +46434,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/nextcloud-talk", "help": "OpenClaw Nextcloud Talk channel plugin (plugin: nextcloud-talk)", "hasChildren": true @@ -41131,7 +46448,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/nextcloud-talk Config", "help": "Plugin-defined config payload for nextcloud-talk.", "hasChildren": false @@ -41143,7 +46462,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/nextcloud-talk", "hasChildren": false }, @@ -41154,7 +46475,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41166,7 +46489,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41178,7 +46503,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/nostr", "help": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs (plugin: nostr)", "hasChildren": true @@ -41190,7 +46517,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/nostr Config", "help": "Plugin-defined config payload for nostr.", "hasChildren": false @@ -41202,7 +46531,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/nostr", "hasChildren": false }, @@ -41213,7 +46544,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41225,7 +46558,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41237,7 +46572,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/ollama-provider", "help": "OpenClaw Ollama provider plugin (plugin: ollama)", "hasChildren": true @@ -41249,7 +46586,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/ollama-provider Config", "help": "Plugin-defined config payload for ollama.", "hasChildren": false @@ -41261,7 +46600,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/ollama-provider", "hasChildren": false }, @@ -41272,7 +46613,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41284,7 +46627,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41296,7 +46641,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "OpenProse", "help": "OpenProse VM skill pack with a /prose slash command. (plugin: open-prose)", "hasChildren": true @@ -41308,7 +46655,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "OpenProse Config", "help": "Plugin-defined config payload for open-prose.", "hasChildren": false @@ -41320,7 +46669,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable OpenProse", "hasChildren": false }, @@ -41331,7 +46682,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41343,7 +46696,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41355,7 +46710,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Phone Control", "help": "Arm/disarm high-risk phone node commands (camera/screen/writes) with an optional auto-expiry. (plugin: phone-control)", "hasChildren": true @@ -41367,7 +46724,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Phone Control Config", "help": "Plugin-defined config payload for phone-control.", "hasChildren": false @@ -41379,7 +46738,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable Phone Control", "hasChildren": false }, @@ -41390,7 +46751,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41402,7 +46765,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41414,7 +46779,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "qwen-portal-auth", "help": "Plugin entry for qwen-portal-auth.", "hasChildren": true @@ -41426,7 +46793,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "qwen-portal-auth Config", "help": "Plugin-defined config payload for qwen-portal-auth.", "hasChildren": false @@ -41438,7 +46807,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable qwen-portal-auth", "hasChildren": false }, @@ -41449,7 +46820,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41461,7 +46834,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41473,7 +46848,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/sglang-provider", "help": "OpenClaw SGLang provider plugin (plugin: sglang)", "hasChildren": true @@ -41485,7 +46862,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/sglang-provider Config", "help": "Plugin-defined config payload for sglang.", "hasChildren": false @@ -41497,7 +46876,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/sglang-provider", "hasChildren": false }, @@ -41508,7 +46889,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41520,7 +46903,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41532,7 +46917,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/signal", "help": "OpenClaw Signal channel plugin (plugin: signal)", "hasChildren": true @@ -41544,7 +46931,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/signal Config", "help": "Plugin-defined config payload for signal.", "hasChildren": false @@ -41556,7 +46945,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/signal", "hasChildren": false }, @@ -41567,7 +46958,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41579,7 +46972,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41591,7 +46986,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/slack", "help": "OpenClaw Slack channel plugin (plugin: slack)", "hasChildren": true @@ -41603,7 +47000,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/slack Config", "help": "Plugin-defined config payload for slack.", "hasChildren": false @@ -41615,7 +47014,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/slack", "hasChildren": false }, @@ -41626,7 +47027,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41638,7 +47041,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41650,7 +47055,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/synology-chat", "help": "Synology Chat channel plugin for OpenClaw (plugin: synology-chat)", "hasChildren": true @@ -41662,7 +47069,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/synology-chat Config", "help": "Plugin-defined config payload for synology-chat.", "hasChildren": false @@ -41674,7 +47083,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/synology-chat", "hasChildren": false }, @@ -41685,7 +47096,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41697,7 +47110,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41709,7 +47124,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Talk Voice", "help": "Manage Talk voice selection (list/set). (plugin: talk-voice)", "hasChildren": true @@ -41721,7 +47138,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Talk Voice Config", "help": "Plugin-defined config payload for talk-voice.", "hasChildren": false @@ -41733,7 +47152,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable Talk Voice", "hasChildren": false }, @@ -41744,7 +47165,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41756,7 +47179,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41768,7 +47193,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/telegram", "help": "OpenClaw Telegram channel plugin (plugin: telegram)", "hasChildren": true @@ -41780,7 +47207,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/telegram Config", "help": "Plugin-defined config payload for telegram.", "hasChildren": false @@ -41792,7 +47221,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/telegram", "hasChildren": false }, @@ -41803,7 +47234,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41815,7 +47248,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41827,7 +47262,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Thread Ownership", "help": "Prevents multiple agents from responding in the same Slack thread. Uses HTTP calls to the slack-forwarder ownership API. (plugin: thread-ownership)", "hasChildren": true @@ -41839,7 +47276,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Thread Ownership Config", "help": "Plugin-defined config payload for thread-ownership.", "hasChildren": true @@ -41851,7 +47290,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "A/B Test Channels", "help": "Slack channel IDs where thread ownership is enforced", "hasChildren": true @@ -41873,7 +47314,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Forwarder URL", "help": "Base URL of the slack-forwarder ownership API (default: http://slack-forwarder:8750)", "hasChildren": false @@ -41885,7 +47328,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Enable Thread Ownership", "hasChildren": false }, @@ -41896,7 +47341,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41908,7 +47355,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41920,7 +47369,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/tlon", "help": "OpenClaw Tlon/Urbit channel plugin (plugin: tlon)", "hasChildren": true @@ -41932,7 +47383,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/tlon Config", "help": "Plugin-defined config payload for tlon.", "hasChildren": false @@ -41944,7 +47397,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/tlon", "hasChildren": false }, @@ -41955,7 +47410,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41967,7 +47424,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41979,7 +47438,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/twitch", "help": "OpenClaw Twitch channel plugin (plugin: twitch)", "hasChildren": true @@ -41991,7 +47452,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/twitch Config", "help": "Plugin-defined config payload for twitch.", "hasChildren": false @@ -42003,7 +47466,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/twitch", "hasChildren": false }, @@ -42014,7 +47479,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -42026,7 +47493,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -42038,7 +47507,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/vllm-provider", "help": "OpenClaw vLLM provider plugin (plugin: vllm)", "hasChildren": true @@ -42050,7 +47521,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/vllm-provider Config", "help": "Plugin-defined config payload for vllm.", "hasChildren": false @@ -42062,7 +47535,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/vllm-provider", "hasChildren": false }, @@ -42073,7 +47548,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -42085,7 +47562,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -42097,7 +47576,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/voice-call", "help": "OpenClaw voice-call plugin (plugin: voice-call)", "hasChildren": true @@ -42109,7 +47590,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/voice-call Config", "help": "Plugin-defined config payload for voice-call.", "hasChildren": true @@ -42121,7 +47604,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Inbound Allowlist", "hasChildren": true }, @@ -42152,7 +47637,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "From Number", "hasChildren": false }, @@ -42163,7 +47650,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Inbound Greeting", "hasChildren": false }, @@ -42172,10 +47661,17 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["disabled", "allowlist", "pairing", "open"], + "enumValues": [ + "disabled", + "allowlist", + "pairing", + "open" + ], "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Inbound Policy", "hasChildren": false }, @@ -42214,10 +47710,15 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["notify", "conversation"], + "enumValues": [ + "notify", + "conversation" + ], "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default Call Mode", "hasChildren": false }, @@ -42228,7 +47729,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Notify Hangup Delay (sec)", "hasChildren": false }, @@ -42267,10 +47770,17 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["telnyx", "twilio", "plivo", "mock"], + "enumValues": [ + "telnyx", + "twilio", + "plivo", + "mock" + ], "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Provider", "help": "Use twilio, telnyx, or mock for dev/no-network.", "hasChildren": false @@ -42282,7 +47792,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Public Webhook URL", "hasChildren": false }, @@ -42293,7 +47805,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Response Model", "hasChildren": false }, @@ -42304,7 +47818,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Response System Prompt", "hasChildren": false }, @@ -42315,7 +47831,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "performance"], + "tags": [ + "advanced", + "performance" + ], "label": "Response Timeout (ms)", "hasChildren": false }, @@ -42346,7 +47865,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Webhook Bind", "hasChildren": false }, @@ -42357,7 +47878,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Webhook Path", "hasChildren": false }, @@ -42368,7 +47891,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Webhook Port", "hasChildren": false }, @@ -42389,7 +47914,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Skip Signature Verification", "hasChildren": false }, @@ -42410,7 +47937,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "storage"], + "tags": [ + "advanced", + "storage" + ], "label": "Call Log Store Path", "hasChildren": false }, @@ -42431,7 +47961,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable Streaming", "hasChildren": false }, @@ -42472,7 +48004,11 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["advanced", "auth", "security"], + "tags": [ + "advanced", + "auth", + "security" + ], "label": "OpenAI Realtime API Key", "hasChildren": false }, @@ -42503,7 +48039,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "storage"], + "tags": [ + "advanced", + "storage" + ], "label": "Media Stream Path", "hasChildren": false }, @@ -42514,7 +48053,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "media"], + "tags": [ + "advanced", + "media" + ], "label": "Realtime STT Model", "hasChildren": false }, @@ -42523,7 +48065,9 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["openai-realtime"], + "enumValues": [ + "openai-realtime" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -42564,7 +48108,9 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["openai"], + "enumValues": [ + "openai" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -42585,10 +48131,16 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["off", "serve", "funnel"], + "enumValues": [ + "off", + "serve", + "funnel" + ], "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Tailscale Mode", "hasChildren": false }, @@ -42599,7 +48151,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "storage"], + "tags": [ + "advanced", + "storage" + ], "label": "Tailscale Path", "hasChildren": false }, @@ -42620,7 +48175,10 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "label": "Telnyx API Key", "hasChildren": false }, @@ -42631,7 +48189,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Telnyx Connection ID", "hasChildren": false }, @@ -42642,7 +48202,9 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["security"], + "tags": [ + "security" + ], "label": "Telnyx Public Key", "hasChildren": false }, @@ -42653,7 +48215,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default To Number", "hasChildren": false }, @@ -42682,7 +48246,12 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["off", "always", "inbound", "tagged"], + "enumValues": [ + "off", + "always", + "inbound", + "tagged" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -42815,7 +48384,12 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["advanced", "auth", "media", "security"], + "tags": [ + "advanced", + "auth", + "media", + "security" + ], "label": "ElevenLabs API Key", "hasChildren": false }, @@ -42824,7 +48398,11 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["auto", "on", "off"], + "enumValues": [ + "auto", + "on", + "off" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -42837,7 +48415,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "media"], + "tags": [ + "advanced", + "media" + ], "label": "ElevenLabs Base URL", "hasChildren": false }, @@ -42858,7 +48439,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "media", "models"], + "tags": [ + "advanced", + "media", + "models" + ], "label": "ElevenLabs Model ID", "hasChildren": false }, @@ -42879,7 +48464,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "media"], + "tags": [ + "advanced", + "media" + ], "label": "ElevenLabs Voice ID", "hasChildren": false }, @@ -42968,7 +48556,10 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["final", "all"], + "enumValues": [ + "final", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -43081,7 +48672,12 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["advanced", "auth", "media", "security"], + "tags": [ + "advanced", + "auth", + "media", + "security" + ], "label": "OpenAI API Key", "hasChildren": false }, @@ -43112,7 +48708,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "media", "models"], + "tags": [ + "advanced", + "media", + "models" + ], "label": "OpenAI TTS Model", "hasChildren": false }, @@ -43133,7 +48733,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "media"], + "tags": [ + "advanced", + "media" + ], "label": "OpenAI TTS Voice", "hasChildren": false }, @@ -43152,10 +48755,17 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["openai", "elevenlabs", "edge"], + "enumValues": [ + "openai", + "elevenlabs", + "edge" + ], "deprecated": false, "sensitive": false, - "tags": ["advanced", "media"], + "tags": [ + "advanced", + "media" + ], "label": "TTS Provider Override", "help": "Deep-merges with messages.tts (Edge is ignored for calls).", "hasChildren": false @@ -43197,7 +48807,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "advanced"], + "tags": [ + "access", + "advanced" + ], "label": "Allow ngrok Free Tier (Loopback Bypass)", "hasChildren": false }, @@ -43208,7 +48821,11 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["advanced", "auth", "security"], + "tags": [ + "advanced", + "auth", + "security" + ], "label": "ngrok Auth Token", "hasChildren": false }, @@ -43219,7 +48836,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ngrok Domain", "hasChildren": false }, @@ -43228,10 +48847,17 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["none", "ngrok", "tailscale-serve", "tailscale-funnel"], + "enumValues": [ + "none", + "ngrok", + "tailscale-serve", + "tailscale-funnel" + ], "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Tunnel Provider", "hasChildren": false }, @@ -43252,7 +48878,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Twilio Account SID", "hasChildren": false }, @@ -43263,7 +48891,10 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "label": "Twilio Auth Token", "hasChildren": false }, @@ -43334,7 +48965,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/voice-call", "hasChildren": false }, @@ -43345,7 +48978,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -43357,7 +48992,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -43369,7 +49006,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/whatsapp", "help": "OpenClaw WhatsApp channel plugin (plugin: whatsapp)", "hasChildren": true @@ -43381,7 +49020,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/whatsapp Config", "help": "Plugin-defined config payload for whatsapp.", "hasChildren": false @@ -43393,7 +49034,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/whatsapp", "hasChildren": false }, @@ -43404,7 +49047,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -43416,7 +49061,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -43428,7 +49075,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/zalo", "help": "OpenClaw Zalo channel plugin (plugin: zalo)", "hasChildren": true @@ -43440,7 +49089,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/zalo Config", "help": "Plugin-defined config payload for zalo.", "hasChildren": false @@ -43452,7 +49103,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/zalo", "hasChildren": false }, @@ -43463,7 +49116,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -43475,7 +49130,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -43487,7 +49144,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/zalouser", "help": "OpenClaw Zalo Personal Account plugin via native zca-js integration (plugin: zalouser)", "hasChildren": true @@ -43499,7 +49158,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/zalouser Config", "help": "Plugin-defined config payload for zalouser.", "hasChildren": false @@ -43511,7 +49172,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/zalouser", "hasChildren": false }, @@ -43522,7 +49185,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -43534,7 +49199,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -43546,7 +49213,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Install Records", "help": "CLI-managed install metadata (used by `openclaw plugins update` to locate install sources).", "hasChildren": true @@ -43568,7 +49237,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Install Time", "help": "ISO timestamp of last install/update.", "hasChildren": false @@ -43580,7 +49251,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Plugin Install Path", "help": "Resolved install directory (usually ~/.openclaw/extensions/).", "hasChildren": false @@ -43592,7 +49265,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Resolved Integrity", "help": "Resolved npm dist integrity hash for the fetched artifact (if reported by npm).", "hasChildren": false @@ -43604,7 +49279,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Resolution Time", "help": "ISO timestamp when npm package metadata was last resolved for this install record.", "hasChildren": false @@ -43616,7 +49293,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Resolved Package Name", "help": "Resolved npm package name from the fetched artifact.", "hasChildren": false @@ -43628,7 +49307,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Resolved Package Spec", "help": "Resolved exact npm spec (@) from the fetched artifact.", "hasChildren": false @@ -43640,7 +49321,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Resolved Package Version", "help": "Resolved npm package version from the fetched artifact (useful for non-pinned specs).", "hasChildren": false @@ -43652,7 +49335,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Resolved Shasum", "help": "Resolved npm dist shasum for the fetched artifact (if reported by npm).", "hasChildren": false @@ -43664,7 +49349,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Install Source", "help": "Install source (\"npm\", \"archive\", or \"path\").", "hasChildren": false @@ -43676,7 +49363,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Plugin Install Source Path", "help": "Original archive/path used for install (if any).", "hasChildren": false @@ -43688,7 +49377,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Install Spec", "help": "Original npm spec used for install (if source is npm).", "hasChildren": false @@ -43700,7 +49391,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Install Version", "help": "Version recorded at install time (if available).", "hasChildren": false @@ -43712,7 +49405,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Loader", "help": "Plugin loader configuration group for specifying filesystem paths where plugins are discovered. Keep load paths explicit and reviewed to avoid accidental untrusted extension loading.", "hasChildren": true @@ -43724,7 +49419,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Plugin Load Paths", "help": "Additional plugin files or directories scanned by the loader beyond built-in defaults. Use dedicated extension directories and avoid broad paths with unrelated executable content.", "hasChildren": true @@ -43746,7 +49443,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Slots", "help": "Selects which plugins own exclusive runtime slots such as memory so only one plugin provides that capability. Use explicit slot ownership to avoid overlapping providers with conflicting behavior.", "hasChildren": true @@ -43758,7 +49457,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Context Engine Plugin", "help": "Selects the active context engine plugin by id so one plugin provides context orchestration behavior.", "hasChildren": false @@ -43770,7 +49471,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Plugin", "help": "Select the active memory plugin by id, or \"none\" to disable memory plugins.", "hasChildren": false @@ -44102,7 +49805,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session", "help": "Global session routing, reset, delivery policy, and maintenance controls for conversation history behavior. Keep defaults unless you need stricter isolation, retention, or delivery constraints.", "hasChildren": true @@ -44114,7 +49819,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Agent-to-Agent", "help": "Groups controls for inter-agent session exchanges, including loop prevention limits on reply chaining. Keep defaults unless you run advanced agent-to-agent automation with strict turn caps.", "hasChildren": true @@ -44126,7 +49833,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "Agent-to-Agent Ping-Pong Turns", "help": "Max reply-back turns between requester and target agents during agent-to-agent exchanges (0-5). Use lower values to hard-limit chatter loops and preserve predictable run completion.", "hasChildren": false @@ -44138,7 +49848,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "DM Session Scope", "help": "DM session scoping: \"main\" keeps continuity, while \"per-peer\", \"per-channel-peer\", and \"per-account-channel-peer\" increase isolation. Use isolated modes for shared inboxes or multi-account deployments.", "hasChildren": false @@ -44150,7 +49862,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Identity Links", "help": "Maps canonical identities to provider-prefixed peer IDs so equivalent users resolve to one DM thread (example: telegram:123456). Use this when the same human appears across multiple channels or accounts.", "hasChildren": true @@ -44182,7 +49896,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Idle Minutes", "help": "Applies a legacy idle reset window in minutes for session reuse behavior across inactivity gaps. Use this only for compatibility and prefer structured reset policies under session.reset/session.resetByType.", "hasChildren": false @@ -44194,7 +49910,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Main Key", "help": "Overrides the canonical main session key used for continuity when dmScope or routing logic points to \"main\". Use a stable value only if you intentionally need custom session anchoring.", "hasChildren": false @@ -44206,7 +49924,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Maintenance", "help": "Automatic session-store maintenance controls for pruning age, entry caps, and file rotation behavior. Start in warn mode to observe impact, then enforce once thresholds are tuned.", "hasChildren": true @@ -44214,11 +49934,16 @@ { "path": "session.maintenance.highWaterBytes", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Disk High-water Target", "help": "Target size after disk-budget cleanup (high-water mark). Defaults to 80% of maxDiskBytes; set explicitly for tighter reclaim behavior on constrained disks.", "hasChildren": false @@ -44226,11 +49951,17 @@ { "path": "session.maintenance.maxDiskBytes", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "Session Max Disk Budget", "help": "Optional per-agent sessions-directory disk budget (for example `500mb`). Use this to cap session storage per agent; when exceeded, warn mode reports pressure and enforce mode performs oldest-first cleanup.", "hasChildren": false @@ -44242,7 +49973,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "Session Max Entries", "help": "Caps total session entry count retained in the store to prevent unbounded growth over time. Use lower limits for constrained environments, or higher limits when longer history is required.", "hasChildren": false @@ -44252,10 +49986,15 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["enforce", "warn"], + "enumValues": [ + "enforce", + "warn" + ], "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Maintenance Mode", "help": "Determines whether maintenance policies are only reported (\"warn\") or actively applied (\"enforce\"). Keep \"warn\" during rollout and switch to \"enforce\" after validating safe thresholds.", "hasChildren": false @@ -44263,11 +50002,16 @@ { "path": "session.maintenance.pruneAfter", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Prune After", "help": "Removes entries older than this duration (for example `30d` or `12h`) during maintenance passes. Use this as the primary age-retention control and align it with data retention policy.", "hasChildren": false @@ -44279,7 +50023,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Prune Days (Deprecated)", "help": "Deprecated age-retention field kept for compatibility with legacy configs using day counts. Use session.maintenance.pruneAfter instead so duration syntax and behavior are consistent.", "hasChildren": false @@ -44287,11 +50033,17 @@ { "path": "session.maintenance.resetArchiveRetention", "kind": "core", - "type": ["boolean", "number", "string"], + "type": [ + "boolean", + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset Archive Retention", "help": "Retention for reset transcript archives (`*.reset.`). Accepts a duration (for example `30d`), or `false` to disable cleanup. Defaults to pruneAfter so reset artifacts do not grow forever.", "hasChildren": false @@ -44299,11 +50051,16 @@ { "path": "session.maintenance.rotateBytes", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Rotate Size", "help": "Rotates the session store when file size exceeds a threshold such as `10mb` or `1gb`. Use this to bound single-file growth and keep backup/restore operations manageable.", "hasChildren": false @@ -44315,7 +50072,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["auth", "performance", "security", "storage"], + "tags": [ + "auth", + "performance", + "security", + "storage" + ], "label": "Session Parent Fork Max Tokens", "help": "Maximum parent-session token count allowed for thread/session inheritance forking. If the parent exceeds this, OpenClaw starts a fresh thread session instead of forking; set 0 to disable this protection.", "hasChildren": false @@ -44327,7 +50089,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset Policy", "help": "Defines the default reset policy object used when no type-specific or channel-specific override applies. Set this first, then layer resetByType or resetByChannel only where behavior must differ.", "hasChildren": true @@ -44339,7 +50103,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Daily Reset Hour", "help": "Sets local-hour boundary (0-23) for daily reset mode so sessions roll over at predictable times. Use with mode=daily and align to operator timezone expectations for human-readable behavior.", "hasChildren": false @@ -44351,7 +50117,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset Idle Minutes", "help": "Sets inactivity window before reset for idle mode and can also act as secondary guard with daily mode. Use larger values to preserve continuity or smaller values for fresher short-lived threads.", "hasChildren": false @@ -44363,7 +50131,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset Mode", "help": "Selects reset strategy: \"daily\" resets at a configured hour and \"idle\" resets after inactivity windows. Keep one clear mode per policy to avoid surprising context turnover patterns.", "hasChildren": false @@ -44375,7 +50145,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset by Channel", "help": "Provides channel-specific reset overrides keyed by provider/channel id for fine-grained behavior control. Use this only when one channel needs exceptional reset behavior beyond type-level policies.", "hasChildren": true @@ -44427,7 +50199,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset by Chat Type", "help": "Overrides reset behavior by chat type (direct, group, thread) when defaults are not sufficient. Use this when group/thread traffic needs different reset cadence than direct messages.", "hasChildren": true @@ -44439,7 +50213,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset (Direct)", "help": "Defines reset policy for direct chats and supersedes the base session.reset configuration for that type. Use this as the canonical direct-message override instead of the legacy dm alias.", "hasChildren": true @@ -44481,7 +50257,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset (DM Deprecated Alias)", "help": "Deprecated alias for direct reset behavior kept for backward compatibility with older configs. Use session.resetByType.direct instead so future tooling and validation remain consistent.", "hasChildren": true @@ -44523,7 +50301,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset (Group)", "help": "Defines reset policy for group chat sessions where continuity and noise patterns differ from DMs. Use shorter idle windows for busy groups if context drift becomes a problem.", "hasChildren": true @@ -44565,7 +50345,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset (Thread)", "help": "Defines reset policy for thread-scoped sessions, including focused channel thread workflows. Use this when thread sessions should expire faster or slower than other chat types.", "hasChildren": true @@ -44607,7 +50389,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset Triggers", "help": "Lists message triggers that force a session reset when matched in inbound content. Use sparingly for explicit reset phrases so context is not dropped unexpectedly during normal conversation.", "hasChildren": true @@ -44629,7 +50413,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Scope", "help": "Sets base session grouping strategy: \"per-sender\" isolates by sender and \"global\" shares one session per channel context. Keep \"per-sender\" for safer multi-user behavior unless deliberate shared context is required.", "hasChildren": false @@ -44641,7 +50427,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Session Send Policy", "help": "Controls cross-session send permissions using allow/deny rules evaluated against channel, chatType, and key prefixes. Use this to fence where session tools can deliver messages in complex environments.", "hasChildren": true @@ -44653,7 +50442,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Session Send Policy Default Action", "help": "Sets fallback action when no sendPolicy rule matches: \"allow\" or \"deny\". Keep \"allow\" for simpler setups, or choose \"deny\" when you require explicit allow rules for every destination.", "hasChildren": false @@ -44665,7 +50457,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Session Send Policy Rules", "help": "Ordered allow/deny rules evaluated before the default action, for example `{ action: \"deny\", match: { channel: \"discord\" } }`. Put most specific rules first so broad rules do not shadow exceptions.", "hasChildren": true @@ -44687,7 +50482,10 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Session Send Rule Action", "help": "Defines rule decision as \"allow\" or \"deny\" when the corresponding match criteria are satisfied. Use deny-first ordering when enforcing strict boundaries with explicit allow exceptions.", "hasChildren": false @@ -44699,7 +50497,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Session Send Rule Match", "help": "Defines optional rule match conditions that can combine channel, chatType, and key-prefix constraints. Keep matches narrow so policy intent stays readable and debugging remains straightforward.", "hasChildren": true @@ -44711,7 +50512,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Session Send Rule Channel", "help": "Matches rule application to a specific channel/provider id (for example discord, telegram, slack). Use this when one channel should permit or deny delivery independently of others.", "hasChildren": false @@ -44723,7 +50527,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Session Send Rule Chat Type", "help": "Matches rule application to chat type (direct, group, thread) so behavior varies by conversation form. Use this when DM and group destinations require different safety boundaries.", "hasChildren": false @@ -44735,7 +50542,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Session Send Rule Key Prefix", "help": "Matches a normalized session-key prefix after internal key normalization steps in policy consumers. Use this for general prefix controls, and prefer rawKeyPrefix when exact full-key matching is required.", "hasChildren": false @@ -44747,7 +50557,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Session Send Rule Raw Key Prefix", "help": "Matches the raw, unnormalized session-key prefix for exact full-key policy targeting. Use this when normalized keyPrefix is too broad and you need agent-prefixed or transport-specific precision.", "hasChildren": false @@ -44759,7 +50572,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Store Path", "help": "Sets the session storage file path used to persist session records across restarts. Use an explicit path only when you need custom disk layout, backup routing, or mounted-volume storage.", "hasChildren": false @@ -44771,7 +50586,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Thread Bindings", "help": "Shared defaults for thread-bound session routing behavior across providers that support thread focus workflows. Configure global defaults here and override per channel only when behavior differs.", "hasChildren": true @@ -44783,7 +50600,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Thread Binding Enabled", "help": "Global master switch for thread-bound session routing features and focused thread delivery behavior. Keep enabled for modern thread workflows unless you need to disable thread binding globally.", "hasChildren": false @@ -44795,7 +50614,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Thread Binding Idle Timeout (hours)", "help": "Default inactivity window in hours for thread-bound sessions across providers/channels (0 disables idle auto-unfocus). Default: 24.", "hasChildren": false @@ -44807,7 +50628,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "Thread Binding Max Age (hours)", "help": "Optional hard max age in hours for thread-bound sessions across providers/channels (0 disables hard cap). Default: 0.", "hasChildren": false @@ -44819,7 +50643,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "Session Typing Interval (seconds)", "help": "Controls interval for repeated typing indicators while replies are being prepared in typing-capable channels. Increase to reduce chatty updates or decrease for more active typing feedback.", "hasChildren": false @@ -44831,7 +50658,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Typing Mode", "help": "Controls typing behavior timing: \"never\", \"instant\", \"thinking\", or \"message\" based emission points. Keep conservative modes in high-volume channels to avoid unnecessary typing noise.", "hasChildren": false @@ -44843,7 +50672,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Skills", "hasChildren": true }, @@ -44890,11 +50721,17 @@ { "path": "skills.entries.*.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "hasChildren": true }, { @@ -45103,7 +50940,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Watch Skills", "help": "Enable filesystem watching for skill-definition changes so updates can be applied without full process restart. Keep enabled in development workflows and disable in immutable production images.", "hasChildren": false @@ -45115,7 +50954,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "performance"], + "tags": [ + "automation", + "performance" + ], "label": "Skills Watch Debounce (ms)", "help": "Debounce window in milliseconds for coalescing rapid skill file changes before reload logic runs. Increase to reduce reload churn on frequent writes, or lower for faster edit feedback.", "hasChildren": false @@ -45127,7 +50969,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Talk", "help": "Talk-mode voice synthesis settings for voice identity, model selection, output format, and interruption behavior. Use this section to tune human-facing voice UX while controlling latency and cost.", "hasChildren": true @@ -45135,11 +50979,18 @@ { "path": "talk.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "media", "security"], + "tags": [ + "auth", + "media", + "security" + ], "label": "Talk API Key", "help": "Use this legacy ElevenLabs API key for Talk mode only during migration, and keep secrets in env-backed storage. Prefer talk.providers.elevenlabs.apiKey (fallback: ELEVENLABS_API_KEY).", "hasChildren": true @@ -45181,7 +51032,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Talk Interrupt on Speech", "help": "If true (default), stop assistant speech when the user starts speaking in Talk mode. Keep enabled for conversational turn-taking.", "hasChildren": false @@ -45193,7 +51046,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "models"], + "tags": [ + "media", + "models" + ], "label": "Talk Model ID", "help": "Legacy ElevenLabs model ID for Talk mode (default: eleven_v3). Prefer talk.providers.elevenlabs.modelId.", "hasChildren": false @@ -45205,7 +51061,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Talk Output Format", "help": "Use this legacy ElevenLabs output format for Talk mode (for example pcm_44100 or mp3_44100_128) only during migration. Prefer talk.providers.elevenlabs.outputFormat.", "hasChildren": false @@ -45217,7 +51075,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Talk Active Provider", "help": "Active Talk provider id (for example \"elevenlabs\").", "hasChildren": false @@ -45229,7 +51089,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Talk Provider Settings", "help": "Provider-specific Talk settings keyed by provider id. During migration, prefer this over legacy talk.* keys.", "hasChildren": true @@ -45256,11 +51118,18 @@ { "path": "talk.providers.*.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "media", "security"], + "tags": [ + "auth", + "media", + "security" + ], "label": "Talk Provider API Key", "help": "Provider API key for Talk mode.", "hasChildren": true @@ -45302,7 +51171,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "models"], + "tags": [ + "media", + "models" + ], "label": "Talk Provider Model ID", "help": "Provider default model ID for Talk mode.", "hasChildren": false @@ -45314,7 +51186,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Talk Provider Output Format", "help": "Provider default output format for Talk mode.", "hasChildren": false @@ -45326,7 +51200,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Talk Provider Voice Aliases", "help": "Optional provider voice alias map for Talk directives.", "hasChildren": true @@ -45348,7 +51224,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Talk Provider Voice ID", "help": "Provider default voice ID for Talk mode.", "hasChildren": false @@ -45360,7 +51238,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance"], + "tags": [ + "media", + "performance" + ], "label": "Talk Silence Timeout (ms)", "help": "Milliseconds of user silence before Talk mode finalizes and sends the current transcript. Leave unset to keep the platform default pause window (700 ms on macOS and Android, 900 ms on iOS).", "hasChildren": false @@ -45372,7 +51253,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Talk Voice Aliases", "help": "Use this legacy ElevenLabs voice alias map (for example {\"Clawd\":\"EXAVITQu4vr4xnSDxMaL\"}) only during migration. Prefer talk.providers.elevenlabs.voiceAliases.", "hasChildren": true @@ -45394,7 +51277,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Talk Voice ID", "help": "Legacy ElevenLabs default voice ID for Talk mode. Prefer talk.providers.elevenlabs.voiceId.", "hasChildren": false @@ -45406,7 +51291,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Tools", "help": "Global tool access policy and capability configuration across web, exec, media, messaging, and elevated surfaces. Use this section to constrain risky capabilities before broad rollout.", "hasChildren": true @@ -45418,7 +51305,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Agent-to-Agent Tool Access", "help": "Policy for allowing agent-to-agent tool calls and constraining which target agents can be reached. Keep disabled or tightly scoped unless cross-agent orchestration is intentionally enabled.", "hasChildren": true @@ -45430,7 +51319,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "tools"], + "tags": [ + "access", + "tools" + ], "label": "Agent-to-Agent Target Allowlist", "help": "Allowlist of target agent IDs permitted for agent_to_agent calls when orchestration is enabled. Use explicit allowlists to avoid uncontrolled cross-agent call graphs.", "hasChildren": true @@ -45452,7 +51344,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Enable Agent-to-Agent Tool", "help": "Enables the agent_to_agent tool surface so one agent can invoke another agent at runtime. Keep off in simple deployments and enable only when orchestration value outweighs complexity.", "hasChildren": false @@ -45464,7 +51358,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "tools"], + "tags": [ + "access", + "tools" + ], "label": "Tool Allowlist", "help": "Absolute tool allowlist that replaces profile-derived defaults for strict environments. Use this only when you intentionally run a tightly curated subset of tool capabilities.", "hasChildren": true @@ -45486,7 +51383,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "tools"], + "tags": [ + "access", + "tools" + ], "label": "Tool Allowlist Additions", "help": "Extra tool allowlist entries merged on top of the selected tool profile and default policy. Keep this list small and explicit so audits can quickly identify intentional policy exceptions.", "hasChildren": true @@ -45508,7 +51408,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Tool Policy by Provider", "help": "Per-provider tool allow/deny overrides keyed by channel/provider ID to tailor capabilities by surface. Use this when one provider needs stricter controls than global tool policy.", "hasChildren": true @@ -45600,7 +51502,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "tools"], + "tags": [ + "access", + "tools" + ], "label": "Tool Denylist", "help": "Global tool denylist that blocks listed tools even when profile or provider rules would allow them. Use deny rules for emergency lockouts and long-term defense-in-depth.", "hasChildren": true @@ -45622,7 +51527,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Elevated Tool Access", "help": "Elevated tool access controls for privileged command surfaces that should only be reachable from trusted senders. Keep disabled unless operator workflows explicitly require elevated actions.", "hasChildren": true @@ -45634,7 +51541,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "tools"], + "tags": [ + "access", + "tools" + ], "label": "Elevated Tool Allow Rules", "help": "Sender allow rules for elevated tools, usually keyed by channel/provider identity formats. Use narrow, explicit identities so elevated commands cannot be triggered by unintended users.", "hasChildren": true @@ -45652,7 +51562,10 @@ { "path": "tools.elevated.allowFrom.*.*", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -45666,7 +51579,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Enable Elevated Tool Access", "help": "Enables elevated tool execution path when sender and policy checks pass. Keep disabled in public/shared channels and enable only for trusted owner-operated contexts.", "hasChildren": false @@ -45678,7 +51593,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Exec Tool", "help": "Exec-tool policy grouping for shell execution host, security mode, approval behavior, and runtime bindings. Keep conservative defaults in production and tighten elevated execution paths.", "hasChildren": true @@ -45700,7 +51617,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "tools"], + "tags": [ + "access", + "tools" + ], "label": "apply_patch Model Allowlist", "help": "Optional allowlist of model ids (e.g. \"gpt-5.2\" or \"openai/gpt-5.2\").", "hasChildren": true @@ -45722,7 +51642,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Enable apply_patch", "help": "Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.", "hasChildren": false @@ -45734,7 +51656,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "advanced", "security", "tools"], + "tags": [ + "access", + "advanced", + "security", + "tools" + ], "label": "apply_patch Workspace-Only", "help": "Restrict apply_patch paths to the workspace directory (default: true). Set false to allow writing outside the workspace (dangerous).", "hasChildren": false @@ -45744,10 +51671,16 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["off", "on-miss", "always"], + "enumValues": [ + "off", + "on-miss", + "always" + ], "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Exec Ask", "help": "Approval strategy for when exec commands require human confirmation before running. Use stricter ask behavior in shared channels and lower-friction settings in private operator contexts.", "hasChildren": false @@ -45777,10 +51710,16 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["sandbox", "gateway", "node"], + "enumValues": [ + "sandbox", + "gateway", + "node" + ], "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Exec Host", "help": "Selects execution host strategy for shell commands, typically controlling local vs delegated execution environment. Use the safest host mode that still satisfies your automation requirements.", "hasChildren": false @@ -45792,7 +51731,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Exec Node Binding", "help": "Node binding configuration for exec tooling when command execution is delegated through connected nodes. Use explicit node binding only when multi-node routing is required.", "hasChildren": false @@ -45804,7 +51745,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Exec Notify On Exit", "help": "When true (default), backgrounded exec sessions on exit and node exec lifecycle events enqueue a system event and request a heartbeat.", "hasChildren": false @@ -45816,7 +51759,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Exec Notify On Empty Success", "help": "When true, successful backgrounded exec exits with empty output still enqueue a completion system event (default: false).", "hasChildren": false @@ -45828,7 +51773,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage", "tools"], + "tags": [ + "storage", + "tools" + ], "label": "Exec PATH Prepend", "help": "Directories to prepend to PATH for exec runs (gateway/sandbox).", "hasChildren": true @@ -45850,7 +51798,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage", "tools"], + "tags": [ + "storage", + "tools" + ], "label": "Exec Safe Bin Profiles", "help": "Optional per-binary safe-bin profiles (positional limits + allowed/denied flags).", "hasChildren": true @@ -45932,7 +51883,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Exec Safe Bins", "help": "Allow stdin-only safe binaries to run without explicit allowlist entries.", "hasChildren": true @@ -45954,7 +51907,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage", "tools"], + "tags": [ + "storage", + "tools" + ], "label": "Exec Safe Bin Trusted Dirs", "help": "Additional explicit directories trusted for safe-bin path checks (PATH entries are never auto-trusted).", "hasChildren": true @@ -45974,10 +51930,16 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["deny", "allowlist", "full"], + "enumValues": [ + "deny", + "allowlist", + "full" + ], "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Exec Security", "help": "Execution security posture selector controlling sandbox/approval expectations for command execution. Keep strict security mode for untrusted prompts and relax only for trusted operator workflows.", "hasChildren": false @@ -46009,7 +51971,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Workspace-only FS tools", "help": "Restrict filesystem tools (read/write/edit/apply_patch) to the workspace directory (default: false).", "hasChildren": false @@ -46031,7 +51995,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Enable Link Understanding", "help": "Enable automatic link understanding pre-processing so URLs can be summarized before agent reasoning. Keep enabled for richer context, and disable when strict minimal processing is required.", "hasChildren": false @@ -46043,7 +52009,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "tools"], + "tags": [ + "performance", + "tools" + ], "label": "Link Understanding Max Links", "help": "Maximum number of links expanded per turn during link understanding. Use lower values to control latency/cost in chatty threads and higher values when multi-link context is critical.", "hasChildren": false @@ -46055,7 +52024,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models", "tools"], + "tags": [ + "models", + "tools" + ], "label": "Link Understanding Models", "help": "Preferred model list for link understanding tasks, evaluated in order as fallbacks when supported. Use lightweight models first for routine summarization and heavier models only when needed.", "hasChildren": true @@ -46127,7 +52099,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Link Understanding Scope", "help": "Controls when link understanding runs relative to conversation context and message type. Keep scope conservative to avoid unnecessary fetches on messages where links are not actionable.", "hasChildren": true @@ -46229,7 +52203,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "tools"], + "tags": [ + "performance", + "tools" + ], "label": "Link Understanding Timeout (sec)", "help": "Per-link understanding timeout budget in seconds before unresolved links are skipped. Keep this bounded to avoid long stalls when external sites are slow or unreachable.", "hasChildren": false @@ -46251,7 +52228,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Tool-loop Critical Threshold", "help": "Critical threshold for repetitive patterns when detector is enabled (default: 20).", "hasChildren": false @@ -46273,7 +52252,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Tool-loop Generic Repeat Detection", "help": "Enable generic repeated same-tool/same-params loop detection (default: true).", "hasChildren": false @@ -46285,7 +52266,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Tool-loop Poll No-Progress Detection", "help": "Enable known poll tool no-progress loop detection (default: true).", "hasChildren": false @@ -46297,7 +52280,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Tool-loop Ping-Pong Detection", "help": "Enable ping-pong loop detection (default: true).", "hasChildren": false @@ -46309,7 +52294,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Tool-loop Detection", "help": "Enable repetitive tool-call loop detection and backoff safety checks (default: false).", "hasChildren": false @@ -46321,7 +52308,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["reliability", "tools"], + "tags": [ + "reliability", + "tools" + ], "label": "Tool-loop Global Circuit Breaker Threshold", "help": "Global no-progress breaker threshold (default: 30).", "hasChildren": false @@ -46333,7 +52323,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Tool-loop History Size", "help": "Tool history window size for loop detection (default: 30).", "hasChildren": false @@ -46345,7 +52337,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Tool-loop Warning Threshold", "help": "Warning threshold for repetitive patterns when detector is enabled (default: 10).", "hasChildren": false @@ -46377,7 +52371,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Audio Understanding Attachment Policy", "help": "Attachment policy for audio inputs indicating which uploaded files are eligible for audio processing. Keep restrictive defaults in mixed-content channels to avoid unintended audio workloads.", "hasChildren": true @@ -46469,7 +52466,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Transcript Echo Format", "help": "Format string for the echoed transcript message. Use `{transcript}` as a placeholder for the transcribed text. Default: '📝 \"{transcript}\"'.", "hasChildren": false @@ -46481,7 +52481,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Echo Transcript to Chat", "help": "Echo the audio transcript back to the originating chat before agent processing. When enabled, users immediately see what was heard from their voice note, helping them verify transcription accuracy before the agent acts on it. Default: false.", "hasChildren": false @@ -46493,7 +52496,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Enable Audio Understanding", "help": "Enable audio understanding so voice notes or audio clips can be transcribed/summarized for agent context. Disable when audio ingestion is outside policy or unnecessary for your workflows.", "hasChildren": false @@ -46525,7 +52531,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Audio Understanding Language", "help": "Preferred language hint for audio understanding/transcription when provider support is available. Set this to improve recognition accuracy for known primary languages.", "hasChildren": false @@ -46537,7 +52546,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance", "tools"], + "tags": [ + "media", + "performance", + "tools" + ], "label": "Audio Understanding Max Bytes", "help": "Maximum accepted audio payload size in bytes before processing is rejected or clipped by policy. Set this based on expected recording length and upstream provider limits.", "hasChildren": false @@ -46549,7 +52562,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance", "tools"], + "tags": [ + "media", + "performance", + "tools" + ], "label": "Audio Understanding Max Chars", "help": "Maximum characters retained from audio understanding output to prevent oversized transcript injection. Increase for long-form dictation, or lower to keep conversational turns compact.", "hasChildren": false @@ -46561,7 +52578,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "models", "tools"], + "tags": [ + "media", + "models", + "tools" + ], "label": "Audio Understanding Models", "help": "Ordered model preferences specifically for audio understanding, used before shared media model fallback. Choose models optimized for transcription quality in your primary language/domain.", "hasChildren": true @@ -46799,7 +52820,11 @@ { "path": "tools.media.audio.models.*.providerOptions.*.*", "kind": "core", - "type": ["boolean", "number", "string"], + "type": [ + "boolean", + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -46833,7 +52858,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Audio Understanding Prompt", "help": "Instruction template guiding audio understanding output style, such as concise summary versus near-verbatim transcript. Keep wording consistent so downstream automations can rely on output format.", "hasChildren": false @@ -46861,7 +52889,11 @@ { "path": "tools.media.audio.providerOptions.*.*", "kind": "core", - "type": ["boolean", "number", "string"], + "type": [ + "boolean", + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -46875,7 +52907,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Audio Understanding Scope", "help": "Scope selector for when audio understanding runs across inbound messages and attachments. Keep focused scopes in high-volume channels to reduce cost and avoid accidental transcription.", "hasChildren": true @@ -46977,7 +53012,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance", "tools"], + "tags": [ + "media", + "performance", + "tools" + ], "label": "Audio Understanding Timeout (sec)", "help": "Timeout in seconds for audio understanding execution before the operation is cancelled. Use longer timeouts for long recordings and tighter ones for interactive chat responsiveness.", "hasChildren": false @@ -46989,7 +53028,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance", "tools"], + "tags": [ + "media", + "performance", + "tools" + ], "label": "Media Understanding Concurrency", "help": "Maximum number of concurrent media understanding operations per turn across image, audio, and video tasks. Lower this in resource-constrained deployments to prevent CPU/network saturation.", "hasChildren": false @@ -47011,7 +53054,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Image Understanding Attachment Policy", "help": "Attachment handling policy for image inputs, including which message attachments qualify for image analysis. Use restrictive settings in untrusted channels to reduce unexpected processing.", "hasChildren": true @@ -47123,7 +53169,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Enable Image Understanding", "help": "Enable image understanding so attached or referenced images can be interpreted into textual context. Disable if you need text-only operation or want to avoid image-processing cost.", "hasChildren": false @@ -47165,7 +53214,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance", "tools"], + "tags": [ + "media", + "performance", + "tools" + ], "label": "Image Understanding Max Bytes", "help": "Maximum accepted image payload size in bytes before the item is skipped or truncated by policy. Keep limits realistic for your provider caps and infrastructure bandwidth.", "hasChildren": false @@ -47177,7 +53230,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance", "tools"], + "tags": [ + "media", + "performance", + "tools" + ], "label": "Image Understanding Max Chars", "help": "Maximum characters returned from image understanding output after model response normalization. Use tighter limits to reduce prompt bloat and larger limits for detail-heavy OCR tasks.", "hasChildren": false @@ -47189,7 +53246,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "models", "tools"], + "tags": [ + "media", + "models", + "tools" + ], "label": "Image Understanding Models", "help": "Ordered model preferences specifically for image understanding when you want to override shared media models. Put the most reliable multimodal model first to reduce fallback attempts.", "hasChildren": true @@ -47427,7 +53488,11 @@ { "path": "tools.media.image.models.*.providerOptions.*.*", "kind": "core", - "type": ["boolean", "number", "string"], + "type": [ + "boolean", + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -47461,7 +53526,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Image Understanding Prompt", "help": "Instruction template used for image understanding requests to shape extraction style and detail level. Keep prompts deterministic so outputs stay consistent across turns and channels.", "hasChildren": false @@ -47489,7 +53557,11 @@ { "path": "tools.media.image.providerOptions.*.*", "kind": "core", - "type": ["boolean", "number", "string"], + "type": [ + "boolean", + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -47503,7 +53575,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Image Understanding Scope", "help": "Scope selector for when image understanding is attempted (for example only explicit requests versus broader auto-detection). Keep narrow scope in busy channels to control token and API spend.", "hasChildren": true @@ -47605,7 +53680,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance", "tools"], + "tags": [ + "media", + "performance", + "tools" + ], "label": "Image Understanding Timeout (sec)", "help": "Timeout in seconds for each image understanding request before it is aborted. Increase for high-resolution analysis and lower it for latency-sensitive operator workflows.", "hasChildren": false @@ -47617,7 +53696,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "models", "tools"], + "tags": [ + "media", + "models", + "tools" + ], "label": "Media Understanding Shared Models", "help": "Shared fallback model list used by media understanding tools when modality-specific model lists are not set. Keep this aligned with available multimodal providers to avoid runtime fallback churn.", "hasChildren": true @@ -47855,7 +53938,11 @@ { "path": "tools.media.models.*.providerOptions.*.*", "kind": "core", - "type": ["boolean", "number", "string"], + "type": [ + "boolean", + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -47899,7 +53986,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Video Understanding Attachment Policy", "help": "Attachment eligibility policy for video analysis, defining which message files can trigger video processing. Keep this explicit in shared channels to prevent accidental large media workloads.", "hasChildren": true @@ -48011,7 +54101,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Enable Video Understanding", "help": "Enable video understanding so clips can be summarized into text for downstream reasoning and responses. Disable when processing video is out of policy or too expensive for your deployment.", "hasChildren": false @@ -48053,7 +54146,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance", "tools"], + "tags": [ + "media", + "performance", + "tools" + ], "label": "Video Understanding Max Bytes", "help": "Maximum accepted video payload size in bytes before policy rejection or trimming occurs. Tune this to provider and infrastructure limits to avoid repeated timeout/failure loops.", "hasChildren": false @@ -48065,7 +54162,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance", "tools"], + "tags": [ + "media", + "performance", + "tools" + ], "label": "Video Understanding Max Chars", "help": "Maximum characters retained from video understanding output to control prompt growth. Raise for dense scene descriptions and lower when concise summaries are preferred.", "hasChildren": false @@ -48077,7 +54178,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "models", "tools"], + "tags": [ + "media", + "models", + "tools" + ], "label": "Video Understanding Models", "help": "Ordered model preferences specifically for video understanding before shared media fallback applies. Prioritize models with strong multimodal video support to minimize degraded summaries.", "hasChildren": true @@ -48315,7 +54420,11 @@ { "path": "tools.media.video.models.*.providerOptions.*.*", "kind": "core", - "type": ["boolean", "number", "string"], + "type": [ + "boolean", + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -48349,7 +54458,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Video Understanding Prompt", "help": "Instruction template for video understanding describing desired summary granularity and focus areas. Keep this stable so output quality remains predictable across model/provider fallbacks.", "hasChildren": false @@ -48377,7 +54489,11 @@ { "path": "tools.media.video.providerOptions.*.*", "kind": "core", - "type": ["boolean", "number", "string"], + "type": [ + "boolean", + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -48391,7 +54507,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Video Understanding Scope", "help": "Scope selector controlling when video understanding is attempted across incoming events. Narrow scope in noisy channels, and broaden only where video interpretation is core to workflow.", "hasChildren": true @@ -48493,7 +54612,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance", "tools"], + "tags": [ + "media", + "performance", + "tools" + ], "label": "Video Understanding Timeout (sec)", "help": "Timeout in seconds for each video understanding request before cancellation. Use conservative values in interactive channels and longer values for offline or batch-heavy processing.", "hasChildren": false @@ -48515,7 +54638,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "tools"], + "tags": [ + "access", + "tools" + ], "label": "Allow Cross-Context Messaging", "help": "Legacy override: allow cross-context sends across all providers.", "hasChildren": false @@ -48537,7 +54663,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Enable Message Broadcast", "help": "Enable broadcast action (default: true).", "hasChildren": false @@ -48559,7 +54687,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "tools"], + "tags": [ + "access", + "tools" + ], "label": "Allow Cross-Context (Across Providers)", "help": "Allow sends across different providers (default: false).", "hasChildren": false @@ -48571,7 +54702,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "tools"], + "tags": [ + "access", + "tools" + ], "label": "Allow Cross-Context (Same Provider)", "help": "Allow sends to other channels within the same provider (default: true).", "hasChildren": false @@ -48593,7 +54727,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Cross-Context Marker", "help": "Add a visible origin marker when sending cross-context (default: true).", "hasChildren": false @@ -48605,7 +54741,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Cross-Context Marker Prefix", "help": "Text prefix for cross-context markers (supports \"{channel}\").", "hasChildren": false @@ -48617,7 +54755,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Cross-Context Marker Suffix", "help": "Text suffix for cross-context markers (supports \"{channel}\").", "hasChildren": false @@ -48629,7 +54769,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage", "tools"], + "tags": [ + "storage", + "tools" + ], "label": "Tool Profile", "help": "Global tool profile name used to select a predefined tool policy baseline before applying allow/deny overrides. Use this for consistent environment posture across agents and keep profile names stable.", "hasChildren": false @@ -48641,7 +54784,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage", "tools"], + "tags": [ + "storage", + "tools" + ], "label": "Sandbox Tool Policy", "help": "Tool policy wrapper for sandboxed agent executions so sandbox runs can have distinct capability boundaries. Use this to enforce stronger safety in sandbox contexts.", "hasChildren": true @@ -48653,7 +54799,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage", "tools"], + "tags": [ + "storage", + "tools" + ], "label": "Sandbox Tool Allow/Deny Policy", "help": "Allow/deny tool policy applied when agents run in sandboxed execution environments. Keep policies minimal so sandbox tasks cannot escalate into unnecessary external actions.", "hasChildren": true @@ -48803,10 +54952,18 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["self", "tree", "agent", "all"], + "enumValues": [ + "self", + "tree", + "agent", + "all" + ], "deprecated": false, "sensitive": false, - "tags": ["storage", "tools"], + "tags": [ + "storage", + "tools" + ], "label": "Session Tools Visibility", "help": "Controls which sessions can be targeted by sessions_list/sessions_history/sessions_send. (\"tree\" default = current session + spawned subagent sessions; \"self\" = only current; \"agent\" = any session in the current agent id; \"all\" = any session; cross-agent still requires tools.agentToAgent).", "hasChildren": false @@ -48818,7 +54975,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Subagent Tool Policy", "help": "Tool policy wrapper for spawned subagents to restrict or expand tool availability compared to parent defaults. Use this to keep delegated agent capabilities scoped to task intent.", "hasChildren": true @@ -48830,7 +54989,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Subagent Tool Allow/Deny Policy", "help": "Allow/deny tool policy applied to spawned subagent runtimes for per-subagent hardening. Keep this narrower than parent scope when subagents run semi-autonomous workflows.", "hasChildren": true @@ -48902,7 +55063,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Web Tools", "help": "Web-tool policy grouping for search/fetch providers, limits, and fallback behavior tuning. Keep enabled settings aligned with API key availability and outbound networking policy.", "hasChildren": true @@ -48924,7 +55087,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage", "tools"], + "tags": [ + "performance", + "storage", + "tools" + ], "label": "Web Fetch Cache TTL (min)", "help": "Cache TTL in minutes for web_fetch results.", "hasChildren": false @@ -48936,7 +55103,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Enable Web Fetch Tool", "help": "Enable the web_fetch tool (lightweight HTTP fetch).", "hasChildren": false @@ -48954,11 +55123,18 @@ { "path": "tools.web.fetch.firecrawl.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security", "tools"], + "tags": [ + "auth", + "security", + "tools" + ], "label": "Firecrawl API Key", "help": "Firecrawl API key (fallback: FIRECRAWL_API_KEY env var).", "hasChildren": true @@ -49000,7 +55176,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Firecrawl Base URL", "help": "Firecrawl base URL (e.g. https://api.firecrawl.dev or custom endpoint).", "hasChildren": false @@ -49012,7 +55190,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Enable Firecrawl Fallback", "help": "Enable Firecrawl fallback for web_fetch (if configured).", "hasChildren": false @@ -49024,7 +55204,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "tools"], + "tags": [ + "performance", + "tools" + ], "label": "Firecrawl Cache Max Age (ms)", "help": "Firecrawl maxAge (ms) for cached results when supported by the API.", "hasChildren": false @@ -49036,7 +55219,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Firecrawl Main Content Only", "help": "When true, Firecrawl returns only the main content (default: true).", "hasChildren": false @@ -49048,7 +55233,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "tools"], + "tags": [ + "performance", + "tools" + ], "label": "Firecrawl Timeout (sec)", "help": "Timeout in seconds for Firecrawl requests.", "hasChildren": false @@ -49060,7 +55248,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "tools"], + "tags": [ + "performance", + "tools" + ], "label": "Web Fetch Max Chars", "help": "Max characters returned by web_fetch (truncated).", "hasChildren": false @@ -49072,7 +55263,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "tools"], + "tags": [ + "performance", + "tools" + ], "label": "Web Fetch Hard Max Chars", "help": "Hard cap for web_fetch maxChars (applies to config and tool calls).", "hasChildren": false @@ -49084,7 +55278,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage", "tools"], + "tags": [ + "performance", + "storage", + "tools" + ], "label": "Web Fetch Max Redirects", "help": "Maximum redirects allowed for web_fetch (default: 3).", "hasChildren": false @@ -49096,7 +55294,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Web Fetch Readability Extraction", "help": "Use Readability to extract main content from HTML (fallbacks to basic HTML cleanup).", "hasChildren": false @@ -49108,7 +55308,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "tools"], + "tags": [ + "performance", + "tools" + ], "label": "Web Fetch Timeout (sec)", "help": "Timeout in seconds for web_fetch requests.", "hasChildren": false @@ -49120,7 +55323,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Web Fetch User-Agent", "help": "Override User-Agent header for web_fetch requests.", "hasChildren": false @@ -49138,11 +55343,18 @@ { "path": "tools.web.search.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security", "tools"], + "tags": [ + "auth", + "security", + "tools" + ], "label": "Brave Search API Key", "help": "Brave Search API key (fallback: BRAVE_API_KEY env var).", "hasChildren": true @@ -49194,7 +55406,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Brave Search Mode", "help": "Brave Search mode: \"web\" (URL results) or \"llm-context\" (pre-extracted page content for LLM grounding).", "hasChildren": false @@ -49206,7 +55420,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage", "tools"], + "tags": [ + "performance", + "storage", + "tools" + ], "label": "Web Search Cache TTL (min)", "help": "Cache TTL in minutes for web_search results.", "hasChildren": false @@ -49218,7 +55436,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Enable Web Search Tool", "help": "Enable the web_search tool (requires a provider API key).", "hasChildren": false @@ -49236,11 +55456,18 @@ { "path": "tools.web.search.gemini.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security", "tools"], + "tags": [ + "auth", + "security", + "tools" + ], "label": "Gemini Search API Key", "help": "Gemini API key for Google Search grounding (fallback: GEMINI_API_KEY env var).", "hasChildren": true @@ -49282,7 +55509,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models", "tools"], + "tags": [ + "models", + "tools" + ], "label": "Gemini Search Model", "help": "Gemini model override (default: \"gemini-2.5-flash\").", "hasChildren": false @@ -49300,11 +55530,18 @@ { "path": "tools.web.search.grok.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security", "tools"], + "tags": [ + "auth", + "security", + "tools" + ], "label": "Grok Search API Key", "help": "Grok (xAI) API key (fallback: XAI_API_KEY env var).", "hasChildren": true @@ -49356,7 +55593,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models", "tools"], + "tags": [ + "models", + "tools" + ], "label": "Grok Search Model", "help": "Grok model override (default: \"grok-4-1-fast\").", "hasChildren": false @@ -49374,11 +55614,18 @@ { "path": "tools.web.search.kimi.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security", "tools"], + "tags": [ + "auth", + "security", + "tools" + ], "label": "Kimi Search API Key", "help": "Moonshot/Kimi API key (fallback: KIMI_API_KEY or MOONSHOT_API_KEY env var).", "hasChildren": true @@ -49420,7 +55667,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Kimi Search Base URL", "help": "Kimi base URL override (default: \"https://api.moonshot.ai/v1\").", "hasChildren": false @@ -49432,7 +55681,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models", "tools"], + "tags": [ + "models", + "tools" + ], "label": "Kimi Search Model", "help": "Kimi model override (default: \"moonshot-v1-128k\").", "hasChildren": false @@ -49444,7 +55696,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "tools"], + "tags": [ + "performance", + "tools" + ], "label": "Web Search Max Results", "help": "Number of results to return (1-10).", "hasChildren": false @@ -49462,11 +55717,18 @@ { "path": "tools.web.search.perplexity.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security", "tools"], + "tags": [ + "auth", + "security", + "tools" + ], "label": "Perplexity API Key", "help": "Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var). Direct Perplexity keys default to the Search API; OpenRouter keys use Sonar chat completions.", "hasChildren": true @@ -49508,7 +55770,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Perplexity Base URL", "help": "Optional Perplexity/OpenRouter chat-completions base URL override. Setting this opts Perplexity into the legacy Sonar/OpenRouter compatibility path.", "hasChildren": false @@ -49520,7 +55784,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models", "tools"], + "tags": [ + "models", + "tools" + ], "label": "Perplexity Model", "help": "Optional Sonar/OpenRouter model override (default: \"perplexity/sonar-pro\"). Setting this opts Perplexity into the legacy chat-completions compatibility path.", "hasChildren": false @@ -49532,7 +55799,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Web Search Provider", "help": "Search provider (\"brave\", \"gemini\", \"grok\", \"kimi\", or \"perplexity\"). Auto-detected from available API keys if omitted.", "hasChildren": false @@ -49544,7 +55813,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "tools"], + "tags": [ + "performance", + "tools" + ], "label": "Web Search Timeout (sec)", "help": "Timeout in seconds for web_search requests.", "hasChildren": false @@ -49556,7 +55828,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "UI", "help": "UI presentation settings for accenting and assistant identity shown in control surfaces. Use this for branding and readability customization without changing runtime behavior.", "hasChildren": true @@ -49568,7 +55842,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Assistant Appearance", "help": "Assistant display identity settings for name and avatar shown in UI surfaces. Keep these values aligned with your operator-facing persona and support expectations.", "hasChildren": true @@ -49580,7 +55856,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Assistant Avatar", "help": "Assistant avatar image source used in UI surfaces (URL, path, or data URI depending on runtime support). Use trusted assets and consistent branding dimensions for clean rendering.", "hasChildren": false @@ -49592,7 +55870,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Assistant Name", "help": "Display name shown for the assistant in UI views, chat chrome, and status contexts. Keep this stable so operators can reliably identify which assistant persona is active.", "hasChildren": false @@ -49604,7 +55884,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Accent Color", "help": "Primary accent/seam color used by UI surfaces for emphasis, badges, and visual identity cues. Use high-contrast values that remain readable across light/dark themes.", "hasChildren": false @@ -49616,7 +55898,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Updates", "help": "Update-channel and startup-check behavior for keeping OpenClaw runtime versions current. Use conservative channels in production and more experimental channels only in controlled environments.", "hasChildren": true @@ -49638,7 +55922,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Auto Update Beta Check Interval (hours)", "help": "How often beta-channel checks run in hours (default: 1).", "hasChildren": false @@ -49650,7 +55936,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Auto Update Enabled", "help": "Enable background auto-update for package installs (default: false).", "hasChildren": false @@ -49662,7 +55950,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Auto Update Stable Delay (hours)", "help": "Minimum delay before stable-channel auto-apply starts (default: 6).", "hasChildren": false @@ -49674,7 +55964,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Auto Update Stable Jitter (hours)", "help": "Extra stable-channel rollout spread window in hours (default: 12).", "hasChildren": false @@ -49686,7 +55978,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Update Channel", "help": "Update channel for git + npm installs (\"stable\", \"beta\", or \"dev\").", "hasChildren": false @@ -49698,7 +55992,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], + "tags": [ + "automation" + ], "label": "Update Check on Start", "help": "Check for npm updates when the gateway starts (default: true).", "hasChildren": false @@ -49710,7 +56006,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Web Channel", "help": "Web channel runtime settings for heartbeat and reconnect behavior when operating web-based chat surfaces. Use reconnect values tuned to your network reliability profile and expected uptime needs.", "hasChildren": true @@ -49722,7 +56020,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Web Channel Enabled", "help": "Enables the web channel runtime and related websocket lifecycle behavior. Keep disabled when web chat is unused to reduce active connection management overhead.", "hasChildren": false @@ -49734,7 +56034,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], + "tags": [ + "automation" + ], "label": "Web Channel Heartbeat Interval (sec)", "help": "Heartbeat interval in seconds for web channel connectivity and liveness maintenance. Use shorter intervals for faster detection, or longer intervals to reduce keepalive chatter.", "hasChildren": false @@ -49746,7 +56048,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Web Channel Reconnect Policy", "help": "Reconnect backoff policy for web channel reconnect attempts after transport failure. Keep bounded retries and jitter tuned to avoid thundering-herd reconnect behavior.", "hasChildren": true @@ -49758,7 +56062,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Web Reconnect Backoff Factor", "help": "Exponential backoff multiplier used between reconnect attempts in web channel retry loops. Keep factor above 1 and tune with jitter for stable large-fleet reconnect behavior.", "hasChildren": false @@ -49770,7 +56076,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Web Reconnect Initial Delay (ms)", "help": "Initial reconnect delay in milliseconds before the first retry after disconnection. Use modest delays to recover quickly without immediate retry storms.", "hasChildren": false @@ -49782,7 +56090,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Web Reconnect Jitter", "help": "Randomization factor (0-1) applied to reconnect delays to desynchronize clients after outage events. Keep non-zero jitter in multi-client deployments to reduce synchronized spikes.", "hasChildren": false @@ -49794,7 +56104,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Web Reconnect Max Attempts", "help": "Maximum reconnect attempts before giving up for the current failure sequence (0 means no retries). Use finite caps for controlled failure handling in automation-sensitive environments.", "hasChildren": false @@ -49806,7 +56118,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Web Reconnect Max Delay (ms)", "help": "Maximum reconnect backoff cap in milliseconds to bound retry delay growth over repeated failures. Use a reasonable cap so recovery remains timely after prolonged outages.", "hasChildren": false @@ -49818,7 +56132,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Setup Wizard State", "help": "Setup wizard state tracking fields that record the most recent guided onboarding run details. Keep these fields for observability and troubleshooting of setup flows across upgrades.", "hasChildren": true @@ -49830,7 +56146,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Wizard Last Run Timestamp", "help": "ISO timestamp for when the setup wizard most recently completed on this host. Use this to confirm onboarding recency during support and operational audits.", "hasChildren": false @@ -49842,7 +56160,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Wizard Last Run Command", "help": "Command invocation recorded for the latest wizard run to preserve execution context. Use this to reproduce onboarding steps when verifying setup regressions.", "hasChildren": false @@ -49854,7 +56174,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Wizard Last Run Commit", "help": "Source commit identifier recorded for the last wizard execution in development builds. Use this to correlate onboarding behavior with exact source state during debugging.", "hasChildren": false @@ -49866,7 +56188,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Wizard Last Run Mode", "help": "Wizard execution mode recorded as \"local\" or \"remote\" for the most recent onboarding flow. Use this to understand whether setup targeted direct local runtime or remote gateway topology.", "hasChildren": false @@ -49878,7 +56202,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Wizard Last Run Version", "help": "OpenClaw version recorded at the time of the most recent wizard run on this config. Use this when diagnosing behavior differences across version-to-version onboarding changes.", "hasChildren": false diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index be2c579b614..18baeac12b9 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -1,4 +1,4 @@ -{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":4733} +{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":4889} {"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -101,6 +101,7 @@ {"recordType":"path","path":"agents.defaults.compaction.recentTurnsPreserve","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Compaction Preserve Recent Turns","help":"Number of most recent user/assistant turns kept verbatim outside safeguard summarization (default: 3). Raise this to preserve exact recent dialogue context, or lower it to maximize compaction savings.","hasChildren":false} {"recordType":"path","path":"agents.defaults.compaction.reserveTokens","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["auth","security"],"label":"Compaction Reserve Tokens","help":"Token headroom reserved for reply generation and tool output after compaction runs. Use higher reserves for verbose/tool-heavy sessions, and lower reserves when maximizing retained history matters more.","hasChildren":false} {"recordType":"path","path":"agents.defaults.compaction.reserveTokensFloor","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["auth","security"],"label":"Compaction Reserve Token Floor","help":"Minimum floor enforced for reserveTokens in Pi compaction paths (0 disables the floor guard). Use a non-zero floor to avoid over-aggressive compression under fluctuating token estimates.","hasChildren":false} +{"recordType":"path","path":"agents.defaults.compaction.timeoutSeconds","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Compaction Timeout (Seconds)","help":"Maximum time in seconds allowed for a single compaction operation before it is aborted (default: 900). Increase this for very large sessions that need more time to summarize, or decrease it to fail faster on unresponsive models.","hasChildren":false} {"recordType":"path","path":"agents.defaults.contextPruning","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.defaults.contextPruning.hardClear","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.defaults.contextPruning.hardClear.enabled","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -143,7 +144,7 @@ {"recordType":"path","path":"agents.defaults.heartbeat.prompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.defaults.heartbeat.session","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.defaults.heartbeat.suppressToolErrorWarnings","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"label":"Heartbeat Suppress Tool Error Warnings","help":"Suppress tool error warning payloads during heartbeat runs.","hasChildren":false} -{"recordType":"path","path":"agents.defaults.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, zalouser, zalo, tlon, feishu, nextcloud-talk, msteams, bluebubbles, synology-chat, mattermost, twitch, matrix, nostr.","hasChildren":false} +{"recordType":"path","path":"agents.defaults.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.","hasChildren":false} {"recordType":"path","path":"agents.defaults.heartbeat.to","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.defaults.humanDelay","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.defaults.humanDelay.maxMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Human Delay Max (ms)","help":"Maximum delay in ms for custom humanDelay (default: 2500).","hasChildren":false} @@ -347,7 +348,7 @@ {"recordType":"path","path":"agents.list.*.heartbeat.prompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.heartbeat.session","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.heartbeat.suppressToolErrorWarnings","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"label":"Agent Heartbeat Suppress Tool Error Warnings","help":"Suppress tool error warning payloads during heartbeat runs.","hasChildren":false} -{"recordType":"path","path":"agents.list.*.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, zalouser, zalo, tlon, feishu, nextcloud-talk, msteams, bluebubbles, synology-chat, mattermost, twitch, matrix, nostr.","hasChildren":false} +{"recordType":"path","path":"agents.list.*.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.","hasChildren":false} {"recordType":"path","path":"agents.list.*.heartbeat.to","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.humanDelay","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.list.*.humanDelay.maxMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -912,6 +913,8 @@ {"recordType":"path","path":"channels.discord.accounts.*.guilds.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.accounts.*.guilds.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.discord.accounts.*.guilds.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.discord.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.discord.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -1165,6 +1168,8 @@ {"recordType":"path","path":"channels.discord.guilds.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.guilds.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.discord.guilds.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.discord.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.discord.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.discord.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.discord.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -1280,61 +1285,182 @@ {"recordType":"path","path":"channels.feishu","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Feishu","help":"飞书/Lark enterprise messaging.","hasChildren":true} {"recordType":"path","path":"channels.feishu.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.feishu.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.accounts.*.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.accounts.*.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.accounts.*.appId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.accounts.*.appSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.feishu.accounts.*.appSecret.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.accounts.*.appSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.feishu.accounts.*.appSecret.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.appSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.blockStreamingCoalesce","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.accounts.*.blockStreamingCoalesce.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.blockStreamingCoalesce.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.blockStreamingCoalesce.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.capabilities","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.accounts.*.capabilities.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.chunkMode","kind":"channel","type":"string","required":false,"enumValues":["length","newline"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.accounts.*.connectionMode","kind":"channel","type":"string","required":false,"enumValues":["websocket","webhook"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.dmHistoryLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.dmPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","pairing","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.dms","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.accounts.*.dms.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.accounts.*.dms.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.dms.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.accounts.*.domain","kind":"channel","type":"string","required":false,"enumValues":["feishu","lark"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.accounts.*.encryptKey","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.feishu.accounts.*.encryptKey.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.accounts.*.encryptKey.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.accounts.*.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","allowlist","disabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.accounts.*.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.groupSessionScope","kind":"channel","type":"string","required":false,"enumValues":["group","group_sender","group_topic","group_topic_sender"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools.allow","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools.allow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.topicSessionMode","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.groupSenderAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.accounts.*.groupSenderAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.groupSessionScope","kind":"channel","type":"string","required":false,"enumValues":["group","group_sender","group_topic","group_topic_sender"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.accounts.*.heartbeat.intervalMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.heartbeat.visibility","kind":"channel","type":"string","required":false,"enumValues":["visible","hidden"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.httpTimeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.markdown","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.accounts.*.markdown.mode","kind":"channel","type":"string","required":false,"enumValues":["native","escape","strip"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.markdown.tableMode","kind":"channel","type":"string","required":false,"enumValues":["native","ascii","simple"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.mediaMaxMb","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.accounts.*.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.reactionNotifications","kind":"channel","type":"string","required":false,"enumValues":["off","own","all"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.renderMode","kind":"channel","type":"string","required":false,"enumValues":["auto","raw","card"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.resolveSenderNames","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.streaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.accounts.*.tools.chat","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.tools.doc","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.tools.drive","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.tools.perm","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.tools.scopes","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.tools.wiki","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.topicSessionMode","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.typingIndicator","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.accounts.*.verificationToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.feishu.accounts.*.verificationToken.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.accounts.*.verificationToken.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.accounts.*.webhookHost","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.accounts.*.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.accounts.*.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.feishu.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.appId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.appSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.feishu.appSecret.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.appSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.feishu.appSecret.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.appSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.blockStreamingCoalesce","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.blockStreamingCoalesce.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.blockStreamingCoalesce.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.blockStreamingCoalesce.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.capabilities","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.capabilities.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.chunkMode","kind":"channel","type":"string","required":false,"enumValues":["length","newline"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.feishu.connectionMode","kind":"channel","type":"string","required":false,"enumValues":["websocket","webhook"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.connectionMode","kind":"channel","type":"string","required":true,"enumValues":["websocket","webhook"],"defaultValue":"websocket","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.dmHistoryLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.feishu.dmPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","pairing","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.feishu.domain","kind":"channel","type":"string","required":false,"enumValues":["feishu","lark"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.dmPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","pairing","allowlist"],"defaultValue":"pairing","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.dms","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.dms.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.dms.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.dms.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.domain","kind":"channel","type":"string","required":true,"enumValues":["feishu","lark"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.dynamicAgentCreation","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.dynamicAgentCreation.agentDirTemplate","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.dynamicAgentCreation.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.dynamicAgentCreation.maxAgents","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.dynamicAgentCreation.workspaceTemplate","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.encryptKey","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.feishu.encryptKey.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.encryptKey.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.feishu.encryptKey.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.encryptKey.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.feishu.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.feishu.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","allowlist","disabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","allowlist","disabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.groups.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.groups.*.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.groups.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.groups.*.groupSessionScope","kind":"channel","type":"string","required":false,"enumValues":["group","group_sender","group_topic","group_topic_sender"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.groups.*.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.groups.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.groups.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.groups.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.groups.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.groups.*.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.groups.*.tools.allow","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.groups.*.tools.allow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.groups.*.tools.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.groups.*.tools.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.groups.*.topicSessionMode","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.groupSenderAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.groupSenderAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.groupSessionScope","kind":"channel","type":"string","required":false,"enumValues":["group","group_sender","group_topic","group_topic_sender"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.heartbeat.intervalMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.heartbeat.visibility","kind":"channel","type":"string","required":false,"enumValues":["visible","hidden"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.httpTimeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.markdown","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.markdown.mode","kind":"channel","type":"string","required":false,"enumValues":["native","escape","strip"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.markdown.tableMode","kind":"channel","type":"string","required":false,"enumValues":["native","ascii","simple"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.mediaMaxMb","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.reactionNotifications","kind":"channel","type":"string","required":true,"enumValues":["off","own","all"],"defaultValue":"own","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.renderMode","kind":"channel","type":"string","required":false,"enumValues":["auto","raw","card"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.feishu.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.requireMention","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.resolveSenderNames","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.streaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.feishu.tools.chat","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.tools.doc","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.tools.drive","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.tools.perm","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.tools.scopes","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.tools.wiki","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.topicSessionMode","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.typingIndicator","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.verificationToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.feishu.verificationToken.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.verificationToken.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.feishu.verificationToken.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.verificationToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.webhookHost","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} -{"recordType":"path","path":"channels.feishu.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.feishu.webhookPath","kind":"channel","type":"string","required":true,"defaultValue":"/feishu/events","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.feishu.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.googlechat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Google Chat","help":"Google Workspace Chat app with HTTP webhook.","hasChildren":true} {"recordType":"path","path":"channels.googlechat.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} @@ -1342,6 +1468,7 @@ {"recordType":"path","path":"channels.googlechat.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.googlechat.accounts.*.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.googlechat.accounts.*.allowBots","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.googlechat.accounts.*.appPrincipal","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.googlechat.accounts.*.audience","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.googlechat.accounts.*.audienceType","kind":"channel","type":"string","required":false,"enumValues":["app-url","project-number"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.googlechat.accounts.*.blockStreaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -1377,6 +1504,8 @@ {"recordType":"path","path":"channels.googlechat.accounts.*.groups.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.googlechat.accounts.*.groups.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.googlechat.accounts.*.groups.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.googlechat.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.googlechat.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.googlechat.accounts.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.googlechat.accounts.*.mediaMaxMb","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.googlechat.accounts.*.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -1401,6 +1530,7 @@ {"recordType":"path","path":"channels.googlechat.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.googlechat.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.googlechat.allowBots","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.googlechat.appPrincipal","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.googlechat.audience","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.googlechat.audienceType","kind":"channel","type":"string","required":false,"enumValues":["app-url","project-number"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.googlechat.blockStreaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -1437,6 +1567,8 @@ {"recordType":"path","path":"channels.googlechat.groups.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.googlechat.groups.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.googlechat.groups.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.googlechat.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.googlechat.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.googlechat.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.googlechat.mediaMaxMb","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.googlechat.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -1504,6 +1636,8 @@ {"recordType":"path","path":"channels.imessage.accounts.*.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.imessage.accounts.*.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.imessage.accounts.*.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.imessage.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.imessage.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.imessage.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.imessage.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.imessage.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -1565,6 +1699,8 @@ {"recordType":"path","path":"channels.imessage.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.imessage.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.imessage.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.imessage.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.imessage.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.imessage.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.imessage.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.imessage.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -1969,6 +2105,8 @@ {"recordType":"path","path":"channels.msteams.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.msteams.groupAllowFrom.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.msteams.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.msteams.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.msteams.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.msteams.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.msteams.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.msteams.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -2214,6 +2352,8 @@ {"recordType":"path","path":"channels.signal.accounts.*.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.signal.accounts.*.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.signal.accounts.*.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.signal.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.signal.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.signal.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.signal.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.signal.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -2282,6 +2422,8 @@ {"recordType":"path","path":"channels.signal.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.signal.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.signal.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.signal.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.signal.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.signal.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.signal.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.signal.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -2386,6 +2528,8 @@ {"recordType":"path","path":"channels.slack.accounts.*.dms.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.slack.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.slack.accounts.*.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","disabled","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.slack.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.slack.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.slack.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.slack.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.slack.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -2509,6 +2653,8 @@ {"recordType":"path","path":"channels.slack.dms.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.slack.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.slack.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.slack.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.slack.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.slack.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.slack.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.slack.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -2688,6 +2834,8 @@ {"recordType":"path","path":"channels.telegram.accounts.*.groups.*.topics.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.telegram.accounts.*.groups.*.topics.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.groups.*.topics.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.telegram.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.telegram.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.telegram.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -2862,6 +3010,8 @@ {"recordType":"path","path":"channels.telegram.groups.*.topics.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.telegram.groups.*.topics.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.groups.*.topics.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.telegram.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.telegram.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.telegram.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -3032,6 +3182,8 @@ {"recordType":"path","path":"channels.whatsapp.accounts.*.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.whatsapp.accounts.*.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.whatsapp.accounts.*.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.whatsapp.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.whatsapp.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.whatsapp.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.whatsapp.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.whatsapp.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -3095,6 +3247,8 @@ {"recordType":"path","path":"channels.whatsapp.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.whatsapp.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.whatsapp.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.whatsapp.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"channels.whatsapp.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.whatsapp.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.whatsapp.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.whatsapp.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -3330,6 +3484,8 @@ {"recordType":"path","path":"gateway.auth.trustedProxy.userHeader","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"gateway.bind","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Gateway Bind Mode","help":"Network bind profile: \"auto\", \"lan\", \"loopback\", \"custom\", or \"tailnet\" to control interface exposure. Keep \"loopback\" or \"auto\" for safest local operation unless external clients must connect.","hasChildren":false} {"recordType":"path","path":"gateway.channelHealthCheckMinutes","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network","reliability"],"label":"Gateway Channel Health Check Interval (min)","help":"Interval in minutes for automatic channel health probing and status updates. Use lower intervals for faster detection, or higher intervals to reduce periodic probe noise.","hasChildren":false} +{"recordType":"path","path":"gateway.channelMaxRestartsPerHour","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network","performance"],"label":"Gateway Channel Max Restarts Per Hour","help":"Maximum number of health-monitor-initiated channel restarts allowed within a rolling one-hour window. Once hit, further restarts are skipped until the window expires. Default: 10.","hasChildren":false} +{"recordType":"path","path":"gateway.channelStaleEventThresholdMinutes","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Gateway Channel Stale Event Threshold (min)","help":"How many minutes a connected channel can go without receiving any event before the health monitor treats it as a stale socket and triggers a restart. Default: 30.","hasChildren":false} {"recordType":"path","path":"gateway.controlUi","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Control UI","help":"Control UI hosting settings including enablement, pathing, and browser-origin/auth hardening behavior. Keep UI exposure minimal and pair with strong auth controls before internet-facing deployments.","hasChildren":true} {"recordType":"path","path":"gateway.controlUi.allowedOrigins","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","network"],"label":"Control UI Allowed Origins","help":"Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled.","hasChildren":true} {"recordType":"path","path":"gateway.controlUi.allowedOrigins.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -3584,7 +3740,7 @@ {"recordType":"path","path":"messages.ackReactionScope","kind":"core","type":"string","required":false,"enumValues":["group-mentions","group-all","direct","all","off","none"],"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Ack Reaction Scope","help":"When to send ack reactions (\"group-mentions\", \"group-all\", \"direct\", \"all\", \"off\", \"none\"). \"off\"/\"none\" disables ack reactions entirely.","hasChildren":false} {"recordType":"path","path":"messages.groupChat","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Group Chat Rules","help":"Group-message handling controls including mention triggers and history window sizing. Keep mention patterns narrow so group channels do not trigger on every message.","hasChildren":true} {"recordType":"path","path":"messages.groupChat.historyLimit","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Group History Limit","help":"Maximum number of prior group messages loaded as context per turn for group sessions. Use higher values for richer continuity, or lower values for faster and cheaper responses.","hasChildren":false} -{"recordType":"path","path":"messages.groupChat.mentionPatterns","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Group Mention Patterns","help":"Regex-like patterns used to detect explicit mentions/trigger phrases in group chats. Use precise patterns to reduce false positives in high-volume channels.","hasChildren":true} +{"recordType":"path","path":"messages.groupChat.mentionPatterns","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Group Mention Patterns","help":"Safe case-insensitive regex patterns used to detect explicit mentions/trigger phrases in group chats. Use precise patterns to reduce false positives in high-volume channels; invalid or unsafe nested-repetition patterns are ignored.","hasChildren":true} {"recordType":"path","path":"messages.groupChat.mentionPatterns.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"messages.inbound","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Inbound Debounce","help":"Direct inbound debounce settings used before queue/turn processing starts. Configure this for provider-specific rapid message bursts from the same sender.","hasChildren":true} {"recordType":"path","path":"messages.inbound.byChannel","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Inbound Debounce by Channel (ms)","help":"Per-channel inbound debounce overrides keyed by provider id in milliseconds. Use this where some providers send message fragments more aggressively than others.","hasChildren":true} diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index b87ad930161..a72ad7d76da 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1005,6 +1005,7 @@ Periodic heartbeat runs. defaults: { compaction: { mode: "safeguard", // default | safeguard + timeoutSeconds: 900, reserveTokensFloor: 24000, identifierPolicy: "strict", // strict | off | custom identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom @@ -1023,6 +1024,7 @@ Periodic heartbeat runs. ``` - `mode`: `default` or `safeguard` (chunked summarization for long histories). See [Compaction](/concepts/compaction). +- `timeoutSeconds`: maximum seconds allowed for a single compaction operation before OpenClaw aborts it. Default: `900`. - `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization. - `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`. - `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Defaults to `["Session Startup", "Red Lines"]`; set `[]` to disable reinjection. When unset or explicitly set to that default pair, older `Every Session`/`Safety` headings are also accepted as a legacy fallback. @@ -2488,6 +2490,11 @@ See [Plugins](/tools/plugin). - Relay-backed registrations are delegated to a specific gateway identity. The paired iOS app fetches `gateway.identity.get`, includes that identity in the relay registration, and forwards a registration-scoped send grant to the gateway. Another gateway cannot reuse that stored registration. - `OPENCLAW_APNS_RELAY_BASE_URL` / `OPENCLAW_APNS_RELAY_TIMEOUT_MS`: temporary env overrides for the relay config above. - `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true`: development-only escape hatch for loopback HTTP relay URLs. Production relay URLs should stay on HTTPS. +- `gateway.channelHealthCheckMinutes`: channel health-monitor interval in minutes. Set `0` to disable health-monitor restarts globally. Default: `5`. +- `gateway.channelStaleEventThresholdMinutes`: stale-socket threshold in minutes. Keep this greater than or equal to `gateway.channelHealthCheckMinutes`. Default: `30`. +- `gateway.channelMaxRestartsPerHour`: maximum health-monitor restarts per channel/account in a rolling hour. Default: `10`. +- `channels..healthMonitor.enabled`: per-channel opt-out for health-monitor restarts while keeping the global monitor enabled. +- `channels..accounts..healthMonitor.enabled`: per-account override for multi-account channels. When set, it takes precedence over the channel-level override. - Local gateway call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset. - If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking). - `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 9a047cab857..a699e74652f 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -175,6 +175,36 @@ When validation fails: + + Control how aggressively the gateway restarts channels that look stale: + + ```json5 + { + gateway: { + channelHealthCheckMinutes: 5, + channelStaleEventThresholdMinutes: 30, + channelMaxRestartsPerHour: 10, + }, + channels: { + telegram: { + healthMonitor: { enabled: false }, + accounts: { + alerts: { + healthMonitor: { enabled: true }, + }, + }, + }, + }, + } + ``` + + - Set `gateway.channelHealthCheckMinutes: 0` to disable health-monitor restarts globally. + - `channelStaleEventThresholdMinutes` should be greater than or equal to the check interval. + - Use `channels..healthMonitor.enabled` or `channels..accounts..healthMonitor.enabled` to disable auto-restarts for one channel or account without disabling the global monitor. + - See [Health Checks](/gateway/health) for operational debugging and the [full reference](/gateway/configuration-reference#gateway) for all fields. + + + Sessions control conversation continuity and isolation: diff --git a/docs/gateway/health.md b/docs/gateway/health.md index 8a6f270979a..f8bfd6a319d 100644 --- a/docs/gateway/health.md +++ b/docs/gateway/health.md @@ -24,6 +24,15 @@ Short guide to verify channel connectivity without guessing. - Session store: `ls -l ~/.openclaw/agents//sessions/sessions.json` (path can be overridden in config). Count and recent recipients are surfaced via `status`. - Relink flow: `openclaw channels logout && openclaw channels login --verbose` when status codes 409–515 or `loggedOut` appear in logs. (Note: the QR login flow auto-restarts once for status 515 after pairing.) +## Health monitor config + +- `gateway.channelHealthCheckMinutes`: how often the gateway checks channel health. Default: `5`. Set `0` to disable health-monitor restarts globally. +- `gateway.channelStaleEventThresholdMinutes`: how long a connected channel can stay idle before the health monitor treats it as stale and restarts it. Default: `30`. Keep this greater than or equal to `gateway.channelHealthCheckMinutes`. +- `gateway.channelMaxRestartsPerHour`: rolling one-hour cap for health-monitor restarts per channel/account. Default: `10`. +- `channels..healthMonitor.enabled`: disable health-monitor restarts for a specific channel while leaving global monitoring enabled. +- `channels..accounts..healthMonitor.enabled`: multi-account override that wins over the channel-level setting. +- These per-channel overrides apply to the built-in channel monitors that expose them today: Discord, Google Chat, iMessage, Microsoft Teams, Signal, Slack, Telegram, and WhatsApp. + ## When something fails - `logged out` or status 409–515 → relink with `openclaw channels logout` then `openclaw channels login`. diff --git a/extensions/telegram/src/conversation-route.ts b/extensions/telegram/src/conversation-route.ts index 20137468486..ea48592eadb 100644 --- a/extensions/telegram/src/conversation-route.ts +++ b/extensions/telegram/src/conversation-route.ts @@ -5,12 +5,12 @@ import { getSessionBindingService } from "../../../src/infra/outbound/session-bi import { buildAgentSessionKey, deriveLastRoutePolicy, - pickFirstExistingAgentId, resolveAgentRoute, } from "../../../src/routing/resolve-route.js"; import { buildAgentMainSessionKey, resolveAgentIdFromSessionKey, + sanitizeAgentId, } from "../../../src/routing/session-key.js"; import { buildTelegramGroupPeerId, @@ -56,7 +56,9 @@ export function resolveTelegramConversationRoute(params: { const rawTopicAgentId = params.topicAgentId?.trim(); if (rawTopicAgentId) { - const topicAgentId = pickFirstExistingAgentId(params.cfg, rawTopicAgentId); + // Preserve the configured topic agent ID so topic-bound sessions stay stable + // even when that agent is not present in the current config snapshot. + const topicAgentId = sanitizeAgentId(rawTopicAgentId); route = { ...route, agentId: topicAgentId, From fc2d29ea926f47c428c556e92ec981441228d2a4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 10:50:49 -0700 Subject: [PATCH 006/943] Gateway: tighten forwarded client and pairing guards (#46800) * Gateway: tighten forwarded client and pairing guards * Gateway: make device approval scope checks atomic * Gateway: preserve device approval baseDir compatibility --- CHANGELOG.md | 1 + src/gateway/net.test.ts | 14 ++ src/gateway/net.ts | 3 + src/gateway/server-methods/devices.ts | 13 +- src/gateway/server.canvas-auth.test.ts | 42 +++++- .../server.device-pair-approve-authz.test.ts | 131 ++++++++++++++++++ src/infra/device-pairing.ts | 54 +++++++- 7 files changed, 252 insertions(+), 6 deletions(-) create mode 100644 src/gateway/server.device-pair-approve-authz.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5653cc86e54..d611fcb2043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146) - Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts. - Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`. +- Gateway/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. Thanks @vincentkoc. - Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc. - Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc. - Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. Thanks @vincentkoc. diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 185325d5428..78ec8c05c55 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -209,6 +209,13 @@ describe("resolveClientIp", () => { trustedProxies: ["127.0.0.1"], expected: "10.0.0.9", }, + { + name: "ignores spoofed loopback X-Forwarded-For hops from trusted proxies", + remoteAddr: "10.0.0.50", + forwardedFor: "127.0.0.1", + trustedProxies: ["10.0.0.0/8"], + expected: undefined, + }, { name: "fails closed when all X-Forwarded-For hops are trusted proxies", remoteAddr: "127.0.0.1", @@ -216,6 +223,13 @@ describe("resolveClientIp", () => { trustedProxies: ["127.0.0.1", "::1"], expected: undefined, }, + { + name: "fails closed when all non-loopback X-Forwarded-For hops are trusted proxies", + remoteAddr: "10.0.0.50", + forwardedFor: "10.0.0.2, 10.0.0.1", + trustedProxies: ["10.0.0.0/8"], + expected: undefined, + }, { name: "fails closed when trusted proxy omits forwarding headers", remoteAddr: "127.0.0.1", diff --git a/src/gateway/net.ts b/src/gateway/net.ts index 3ea32fc1659..7a5f2eac76d 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -132,6 +132,9 @@ function resolveForwardedClientIp(params: { // Walk right-to-left and return the first untrusted hop. for (let index = forwardedChain.length - 1; index >= 0; index -= 1) { const hop = forwardedChain[index]; + if (isLoopbackAddress(hop)) { + continue; + } if (!isTrustedProxyAddress(hop, trustedProxies)) { return hop; } diff --git a/src/gateway/server-methods/devices.ts b/src/gateway/server-methods/devices.ts index 4becd52edcc..862aaf95f06 100644 --- a/src/gateway/server-methods/devices.ts +++ b/src/gateway/server-methods/devices.ts @@ -94,7 +94,7 @@ export const deviceHandlers: GatewayRequestHandlers = { undefined, ); }, - "device.pair.approve": async ({ params, respond, context }) => { + "device.pair.approve": async ({ params, respond, context, client }) => { if (!validateDevicePairApproveParams(params)) { respond( false, @@ -109,11 +109,20 @@ export const deviceHandlers: GatewayRequestHandlers = { return; } const { requestId } = params as { requestId: string }; - const approved = await approveDevicePairing(requestId); + const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; + const approved = await approveDevicePairing(requestId, { callerScopes }); if (!approved) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId")); return; } + if (approved.status === "forbidden") { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${approved.missingScope}`), + ); + return; + } context.logGateway.info( `device pairing approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`, ); diff --git a/src/gateway/server.canvas-auth.test.ts b/src/gateway/server.canvas-auth.test.ts index ab0a7c9d89d..5cdc61d57dc 100644 --- a/src/gateway/server.canvas-auth.test.ts +++ b/src/gateway/server.canvas-auth.test.ts @@ -263,7 +263,7 @@ describe("gateway canvas host auth", () => { const scopedA2ui = await fetch( `http://${host}:${listener.port}${scopedCanvasPath(activeNodeCapability, `${A2UI_PATH}/`)}`, ); - expect(scopedA2ui.status).toBe(200); + expect(scopedA2ui.status).toBe(503); await expectWsConnected(`ws://${host}:${listener.port}${activeWsPath}`); @@ -383,4 +383,44 @@ describe("gateway canvas host auth", () => { }); }); }, 60_000); + + test("rejects spoofed loopback forwarding headers from trusted proxies", async () => { + await withTempConfig({ + cfg: { + gateway: { + trustedProxies: ["127.0.0.1"], + }, + }, + run: async () => { + const rateLimiter = createAuthRateLimiter({ + maxAttempts: 1, + windowMs: 60_000, + lockoutMs: 60_000, + exemptLoopback: true, + }); + await withCanvasGatewayHarness({ + resolvedAuth: tokenResolvedAuth, + listenHost: "0.0.0.0", + rateLimiter, + handleHttpRequest: async () => false, + run: async ({ listener }) => { + const headers = { + authorization: "Bearer wrong", + host: "localhost", + "x-forwarded-for": "127.0.0.1, 203.0.113.24", + }; + const first = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, { + headers, + }); + expect(first.status).toBe(401); + + const second = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, { + headers, + }); + expect(second.status).toBe(429); + }, + }); + }, + }); + }, 60_000); }); diff --git a/src/gateway/server.device-pair-approve-authz.test.ts b/src/gateway/server.device-pair-approve-authz.test.ts new file mode 100644 index 00000000000..20c1d6d5959 --- /dev/null +++ b/src/gateway/server.device-pair-approve-authz.test.ts @@ -0,0 +1,131 @@ +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { WebSocket } from "ws"; +import { + loadOrCreateDeviceIdentity, + publicKeyRawBase64UrlFromPem, + type DeviceIdentity, +} from "../infra/device-identity.js"; +import { + approveDevicePairing, + getPairedDevice, + requestDevicePairing, + rotateDeviceToken, +} from "../infra/device-pairing.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { + connectOk, + installGatewayTestHooks, + rpcReq, + startServerWithClient, + trackConnectChallengeNonce, +} from "./test-helpers.js"; + +installGatewayTestHooks({ scope: "suite" }); + +function resolveDeviceIdentityPath(name: string): string { + const root = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir(); + return path.join(root, "test-device-identities", `${name}.json`); +} + +function loadDeviceIdentity(name: string): { + identityPath: string; + identity: DeviceIdentity; + publicKey: string; +} { + const identityPath = resolveDeviceIdentityPath(name); + const identity = loadOrCreateDeviceIdentity(identityPath); + return { + identityPath, + identity, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + }; +} + +async function issuePairingScopedOperator(name: string): Promise<{ + identityPath: string; + deviceId: string; + token: string; +}> { + const loaded = loadDeviceIdentity(name); + const request = await requestDevicePairing({ + deviceId: loaded.identity.deviceId, + publicKey: loaded.publicKey, + role: "operator", + scopes: ["operator.admin"], + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + }); + await approveDevicePairing(request.request.requestId); + const rotated = await rotateDeviceToken({ + deviceId: loaded.identity.deviceId, + role: "operator", + scopes: ["operator.pairing"], + }); + expect(rotated?.token).toBeTruthy(); + return { + identityPath: loaded.identityPath, + deviceId: loaded.identity.deviceId, + token: String(rotated?.token ?? ""), + }; +} + +async function openTrackedWs(port: number): Promise { + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + trackConnectChallengeNonce(ws); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 5_000); + ws.once("open", () => { + clearTimeout(timer); + resolve(); + }); + ws.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + }); + return ws; +} + +describe("gateway device.pair.approve caller scope guard", () => { + test("rejects approving device scopes above the caller session scopes", async () => { + const started = await startServerWithClient("secret"); + const approver = await issuePairingScopedOperator("approve-attacker"); + const pending = loadDeviceIdentity("approve-target"); + + let pairingWs: WebSocket | undefined; + try { + const request = await requestDevicePairing({ + deviceId: pending.identity.deviceId, + publicKey: pending.publicKey, + role: "operator", + scopes: ["operator.admin"], + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + }); + + pairingWs = await openTrackedWs(started.port); + await connectOk(pairingWs, { + skipDefaultAuth: true, + deviceToken: approver.token, + deviceIdentityPath: approver.identityPath, + scopes: ["operator.pairing"], + }); + + const approve = await rpcReq(pairingWs, "device.pair.approve", { + requestId: request.request.requestId, + }); + expect(approve.ok).toBe(false); + expect(approve.error?.message).toBe("missing scope: operator.admin"); + + const paired = await getPairedDevice(pending.identity.deviceId); + expect(paired).toBeNull(); + } finally { + pairingWs?.close(); + started.ws.close(); + await started.server.close(); + started.envSnapshot.restore(); + } + }); +}); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index d16cd06f0cc..b452e951bc8 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -80,6 +80,11 @@ export type DevicePairingList = { paired: PairedDevice[]; }; +export type ApproveDevicePairingResult = + | { status: "approved"; requestId: string; device: PairedDevice } + | { status: "forbidden"; missingScope: string } + | null; + type DevicePairingStateFile = { pendingById: Record; pairedByDeviceId: Record; @@ -246,6 +251,25 @@ 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); @@ -263,6 +287,14 @@ export async function getPairedDevice( return state.pairedByDeviceId[normalizeDeviceId(deviceId)] ?? null; } +export async function getPendingDevicePairing( + requestId: string, + baseDir?: string, +): Promise { + const state = await loadState(baseDir); + return state.pendingById[requestId] ?? null; +} + export async function requestDevicePairing( req: Omit, baseDir?: string, @@ -313,14 +345,30 @@ export async function requestDevicePairing( export async function approveDevicePairing( requestId: string, - baseDir?: string, -): Promise<{ requestId: string; device: PairedDevice } | null> { + optionsOrBaseDir?: { callerScopes?: readonly string[] } | string, + maybeBaseDir?: string, +): Promise { + const options = + typeof optionsOrBaseDir === "string" || optionsOrBaseDir === undefined + ? undefined + : optionsOrBaseDir; + const baseDir = typeof optionsOrBaseDir === "string" ? optionsOrBaseDir : maybeBaseDir; return await withLock(async () => { const state = await loadState(baseDir); const pending = state.pendingById[requestId]; if (!pending) { return null; } + if (pending.role && options?.callerScopes) { + const missingScope = resolveMissingRequestedScope({ + role: pending.role, + requestedScopes: normalizeDeviceAuthScopes(pending.scopes), + callerScopes: options.callerScopes, + }); + if (missingScope) { + return { status: "forbidden", missingScope }; + } + } const now = Date.now(); const existing = state.pairedByDeviceId[pending.deviceId]; const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role); @@ -373,7 +421,7 @@ export async function approveDevicePairing( delete state.pendingById[requestId]; state.pairedByDeviceId[device.deviceId] = device; await persistState(state, baseDir); - return { requestId, device }; + return { status: "approved", requestId, device }; }); } From 630958749c7b23cdd287f489143825b2fbebf149 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 10:54:21 -0700 Subject: [PATCH 007/943] Changelog: note CLI OOM startup fixes (#47525) --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d611fcb2043..65bee8da1aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,9 @@ Docs: https://docs.openclaw.ai - Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc. - ACP: require admin scope for mutating internal actions. (#46789) Thanks @tdjackey and @vincentkoc. - Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman. +- CLI/startup: lazy-load channel add and root help startup paths to trim avoidable RSS and help latency on constrained hosts. (#46784) Thanks @vincentkoc. +- CLI/onboarding: import static provider definitions directly for onboarding model/config helpers so those paths no longer pull provider discovery just for built-in defaults. (#47467) Thanks @vincentkoc. +- CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc. ## 2026.3.13 From 438991b6a430d0187f4320b94c3eb06dfabf0463 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 10:54:46 -0700 Subject: [PATCH 008/943] Commands: lazy-load model picker provider runtime (#47536) * Commands: lazy-load model picker provider runtime * Tests: cover model picker runtime boundary --- src/commands/model-picker.runtime.ts | 7 ++++ src/commands/model-picker.test.ts | 19 +++++---- src/commands/model-picker.ts | 59 ++++++++++++++++++---------- 3 files changed, 54 insertions(+), 31 deletions(-) create mode 100644 src/commands/model-picker.runtime.ts diff --git a/src/commands/model-picker.runtime.ts b/src/commands/model-picker.runtime.ts new file mode 100644 index 00000000000..74c4f68c605 --- /dev/null +++ b/src/commands/model-picker.runtime.ts @@ -0,0 +1,7 @@ +export { + resolveProviderModelPickerEntries, + resolveProviderPluginChoice, + runProviderModelSelectedHook, +} from "../plugins/provider-wizard.js"; +export { resolvePluginProviders } from "../plugins/providers.js"; +export { runProviderPluginAuthMethod } from "./auth-choice.apply.plugin-provider.js"; diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index ef8b6a3887b..ce8c4bfb9f6 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { applyModelAllowlist, @@ -37,19 +37,13 @@ vi.mock("../agents/model-auth.js", () => ({ const resolveProviderModelPickerEntries = vi.hoisted(() => vi.fn(() => [])); const resolveProviderPluginChoice = vi.hoisted(() => vi.fn()); const runProviderModelSelectedHook = vi.hoisted(() => vi.fn(async () => {})); -vi.mock("../plugins/provider-wizard.js", () => ({ +const resolvePluginProviders = vi.hoisted(() => vi.fn(() => [])); +const runProviderPluginAuthMethod = vi.hoisted(() => vi.fn()); +vi.mock("./model-picker.runtime.js", () => ({ resolveProviderModelPickerEntries, resolveProviderPluginChoice, runProviderModelSelectedHook, -})); - -const resolvePluginProviders = vi.hoisted(() => vi.fn(() => [])); -vi.mock("../plugins/providers.js", () => ({ resolvePluginProviders, -})); - -const runProviderPluginAuthMethod = vi.hoisted(() => vi.fn()); -vi.mock("./auth-choice.apply.plugin-provider.js", () => ({ runProviderPluginAuthMethod, })); @@ -77,6 +71,10 @@ function createSelectAllMultiselect() { return vi.fn(async (params) => params.options.map((option: { value: string }) => option.value)); } +beforeEach(() => { + vi.clearAllMocks(); +}); + describe("promptDefaultModel", () => { it("supports configuring vLLM during onboarding", async () => { loadModelCatalog.mockResolvedValue([ @@ -211,6 +209,7 @@ describe("router model filtering", () => { const allowlistCall = multiselect.mock.calls[0]?.[0]; expectRouterModelFiltering(allowlistCall?.options as Array<{ value: string }>); expect(allowlistCall?.searchable).toBe(true); + expect(runProviderPluginAuthMethod).not.toHaveBeenCalled(); }); }); diff --git a/src/commands/model-picker.ts b/src/commands/model-picker.ts index 2e97a01a977..64d9e533e1f 100644 --- a/src/commands/model-picker.ts +++ b/src/commands/model-picker.ts @@ -11,14 +11,8 @@ import { } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; -import { - resolveProviderPluginChoice, - resolveProviderModelPickerEntries, - runProviderModelSelectedHook, -} from "../plugins/provider-wizard.js"; -import { resolvePluginProviders } from "../plugins/providers.js"; +import type { ProviderPlugin } from "../plugins/types.js"; import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; -import { runProviderPluginAuthMethod } from "./auth-choice.apply.plugin-provider.js"; import { formatTokenK } from "./models/shared.js"; import { OPENAI_CODEX_DEFAULT_MODEL } from "./openai-codex-model-default.js"; @@ -49,6 +43,10 @@ type PromptDefaultModelParams = { type PromptDefaultModelResult = { model?: string; config?: OpenClawConfig }; type PromptModelAllowlistResult = { models?: string[] }; +async function loadModelPickerRuntime() { + return import("./model-picker.runtime.js"); +} + function hasAuthForProvider( provider: string, cfg: OpenClawConfig, @@ -295,6 +293,7 @@ export async function promptDefaultModel( options.push({ value: MANUAL_VALUE, label: "Enter model manually" }); } if (includeProviderPluginSetups && agentDir) { + const { resolveProviderModelPickerEntries } = await loadModelPickerRuntime(); options.push( ...resolveProviderModelPickerEntries({ config: cfg, @@ -347,20 +346,24 @@ export async function promptDefaultModel( initialValue: configuredRaw || resolvedKey || undefined, }); } - const pluginProviders = resolvePluginProviders({ - config: cfg, - workspaceDir: params.workspaceDir, - env: params.env, - }); - const pluginResolution = selection.startsWith("provider-plugin:") - ? selection - : selection.includes("/") - ? null - : pluginProviders.some( - (provider) => normalizeProviderId(provider.id) === normalizeProviderId(selection), - ) - ? selection - : null; + + let pluginResolution: string | null = null; + let pluginProviders: ProviderPlugin[] = []; + if (selection.startsWith("provider-plugin:")) { + pluginResolution = selection; + } else if (!selection.includes("/")) { + const { resolvePluginProviders } = await loadModelPickerRuntime(); + pluginProviders = resolvePluginProviders({ + config: cfg, + workspaceDir: params.workspaceDir, + env: params.env, + }); + pluginResolution = pluginProviders.some( + (provider) => normalizeProviderId(provider.id) === normalizeProviderId(selection), + ) + ? selection + : null; + } if (pluginResolution) { if (!agentDir || !params.runtime) { await params.prompter.note( @@ -369,6 +372,19 @@ export async function promptDefaultModel( ); return {}; } + const { + resolvePluginProviders, + resolveProviderPluginChoice, + runProviderModelSelectedHook, + runProviderPluginAuthMethod, + } = await loadModelPickerRuntime(); + if (pluginProviders.length === 0) { + pluginProviders = resolvePluginProviders({ + config: cfg, + workspaceDir: params.workspaceDir, + env: params.env, + }); + } const resolved = resolveProviderPluginChoice({ providers: pluginProviders, choice: pluginResolution, @@ -397,6 +413,7 @@ export async function promptDefaultModel( return { model: applied.defaultModel, config: applied.config }; } const model = String(selection); + const { runProviderModelSelectedHook } = await loadModelPickerRuntime(); await runProviderModelSelectedHook({ config: cfg, model, From c9a8b6f82fbe8b755b3870b1b648232db02ec258 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 12:03:35 -0700 Subject: [PATCH 009/943] chore(fmt): format changes and broken types --- docs/.generated/config-baseline.json | 8086 ++++------------- .../server.device-pair-approve-authz.test.ts | 4 +- src/infra/device-pairing.ts | 14 + 3 files changed, 1690 insertions(+), 6414 deletions(-) diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index f6f854b2946..4974f3a410a 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -8,9 +8,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "ACP", "help": "ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.", "hasChildren": true @@ -22,9 +20,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "ACP Allowed Agents", "help": "Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.", "hasChildren": true @@ -46,9 +42,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "ACP Backend", "help": "Default ACP runtime backend id (for example: acpx). Must match a registered ACP runtime plugin backend.", "hasChildren": false @@ -60,9 +54,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "ACP Default Agent", "help": "Fallback ACP target agent id used when ACP spawns do not specify an explicit target.", "hasChildren": false @@ -84,9 +76,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "ACP Dispatch Enabled", "help": "Independent dispatch gate for ACP session turns (default: true). Set false to keep ACP commands available while blocking ACP turn execution.", "hasChildren": false @@ -98,9 +88,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "ACP Enabled", "help": "Global ACP feature gate. Keep disabled unless ACP runtime + policy are configured.", "hasChildren": false @@ -112,10 +100,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage" - ], + "tags": ["performance", "storage"], "label": "ACP Max Concurrent Sessions", "help": "Maximum concurrently active ACP sessions across this gateway process.", "hasChildren": false @@ -137,9 +122,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "ACP Runtime Install Command", "help": "Optional operator install/setup command shown by `/acp install` and `/acp doctor` when ACP backend wiring is missing.", "hasChildren": false @@ -151,9 +134,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "ACP Runtime TTL (minutes)", "help": "Idle runtime TTL in minutes for ACP session workers before eligible cleanup.", "hasChildren": false @@ -165,9 +146,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "ACP Stream", "help": "ACP streaming projection controls for chunk sizing, metadata visibility, and deduped delivery behavior.", "hasChildren": true @@ -179,9 +158,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "ACP Stream Coalesce Idle (ms)", "help": "Coalescer idle flush window in milliseconds for ACP streamed text before block replies are emitted.", "hasChildren": false @@ -193,9 +170,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "ACP Stream Delivery Mode", "help": "ACP delivery style: live streams projected output incrementally, final_only buffers all projected ACP output until terminal turn events.", "hasChildren": false @@ -207,9 +182,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "ACP Stream Hidden Boundary Separator", "help": "Separator inserted before next visible assistant text when hidden ACP tool lifecycle events occurred (none|space|newline|paragraph). Default: paragraph.", "hasChildren": false @@ -221,9 +194,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "ACP Stream Max Chunk Chars", "help": "Maximum chunk size for ACP streamed block projection before splitting into multiple block replies.", "hasChildren": false @@ -235,9 +206,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "ACP Stream Max Output Chars", "help": "Maximum assistant output characters projected per ACP turn before truncation notice is emitted.", "hasChildren": false @@ -249,10 +218,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage" - ], + "tags": ["performance", "storage"], "label": "ACP Stream Max Session Update Chars", "help": "Maximum characters for projected ACP session/update lines (tool/status updates).", "hasChildren": false @@ -264,9 +230,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "ACP Stream Repeat Suppression", "help": "When true (default), suppress repeated ACP status/tool projection lines in a turn while keeping raw ACP events unchanged.", "hasChildren": false @@ -278,9 +242,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "ACP Stream Tag Visibility", "help": "Per-sessionUpdate visibility overrides for ACP projection (for example usage_update, available_commands_update).", "hasChildren": true @@ -302,9 +264,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Agents", "help": "Agent runtime configuration root covering defaults and explicit agent entries used for routing and execution context. Keep this section explicit so model/tool behavior stays predictable across multi-agent workflows.", "hasChildren": true @@ -316,9 +276,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Agent Defaults", "help": "Shared default settings inherited by agents unless overridden per entry in agents.list. Use defaults to enforce consistent baseline behavior and reduce duplicated per-agent configuration.", "hasChildren": true @@ -430,9 +388,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Bootstrap Max Chars", "help": "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", "hasChildren": false @@ -444,9 +400,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Bootstrap Prompt Truncation Warning", "help": "Inject agent-visible warning text when bootstrap files are truncated: \"off\", \"once\" (default), or \"always\".", "hasChildren": false @@ -458,9 +412,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Bootstrap Total Max Chars", "help": "Max total characters across all injected workspace bootstrap files (default: 150000).", "hasChildren": false @@ -472,9 +424,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "CLI Backends", "help": "Optional CLI backends for text-only fallback (claude-cli, etc.).", "hasChildren": true @@ -896,9 +846,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Compaction", "help": "Compaction tuning for when context nears token limits, including history share, reserve headroom, and pre-compaction memory flush behavior. Use this when long-running sessions need stable continuity under tight context windows.", "hasChildren": true @@ -920,9 +868,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Compaction Identifier Instructions", "help": "Custom identifier-preservation instruction text used when identifierPolicy=\"custom\". Keep this explicit and safety-focused so compaction summaries do not rewrite opaque IDs, URLs, hosts, or ports.", "hasChildren": false @@ -934,9 +880,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Compaction Identifier Policy", "help": "Identifier-preservation policy for compaction summaries: \"strict\" prepends built-in opaque-identifier retention guidance (default), \"off\" disables this prefix, and \"custom\" uses identifierInstructions. Keep \"strict\" unless you have a specific compatibility need.", "hasChildren": false @@ -948,10 +892,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "auth", - "security" - ], + "tags": ["auth", "security"], "label": "Compaction Keep Recent Tokens", "help": "Minimum token budget preserved from the most recent conversation window during compaction. Use higher values to protect immediate context continuity and lower values to keep more long-tail history.", "hasChildren": false @@ -963,9 +904,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Compaction Max History Share", "help": "Maximum fraction of total context budget allowed for retained history after compaction (range 0.1-0.9). Use lower shares for more generation headroom or higher shares for deeper historical continuity.", "hasChildren": false @@ -977,9 +916,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Compaction Memory Flush", "help": "Pre-compaction memory flush settings that run an agentic memory write before heavy compaction. Keep enabled for long sessions so salient context is persisted before aggressive trimming.", "hasChildren": true @@ -991,9 +928,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Compaction Memory Flush Enabled", "help": "Enables pre-compaction memory flush before the runtime performs stronger history reduction near token limits. Keep enabled unless you intentionally disable memory side effects in constrained environments.", "hasChildren": false @@ -1001,16 +936,11 @@ { "path": "agents.defaults.compaction.memoryFlush.forceFlushTranscriptBytes", "kind": "core", - "type": [ - "integer", - "string" - ], + "type": ["integer", "string"], "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Compaction Memory Flush Transcript Size Threshold", "help": "Forces pre-compaction memory flush when transcript file size reaches this threshold (bytes or strings like \"2mb\"). Use this to prevent long-session hangs even when token counters are stale; set to 0 to disable.", "hasChildren": false @@ -1022,9 +952,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Compaction Memory Flush Prompt", "help": "User-prompt template used for the pre-compaction memory flush turn when generating memory candidates. Use this only when you need custom extraction instructions beyond the default memory flush behavior.", "hasChildren": false @@ -1036,10 +964,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "auth", - "security" - ], + "tags": ["auth", "security"], "label": "Compaction Memory Flush Soft Threshold", "help": "Threshold distance to compaction (in tokens) that triggers pre-compaction memory flush execution. Use earlier thresholds for safer persistence, or tighter thresholds for lower flush frequency.", "hasChildren": false @@ -1051,9 +976,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Compaction Memory Flush System Prompt", "help": "System-prompt override for the pre-compaction memory flush turn to control extraction style and safety constraints. Use carefully so custom instructions do not reduce memory quality or leak sensitive context.", "hasChildren": false @@ -1065,9 +988,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Compaction Mode", "help": "Compaction strategy mode: \"default\" uses baseline behavior, while \"safeguard\" applies stricter guardrails to preserve recent context. Keep \"default\" unless you observe aggressive history loss near limit boundaries.", "hasChildren": false @@ -1079,9 +1000,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models" - ], + "tags": ["models"], "label": "Compaction Model Override", "help": "Optional provider/model override used only for compaction summarization. Set this when you want compaction to run on a different model than the session default, and leave it unset to keep using the primary agent model.", "hasChildren": false @@ -1093,9 +1012,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Post-Compaction Context Sections", "help": "AGENTS.md H2/H3 section names re-injected after compaction so the agent reruns critical startup guidance. Leave unset to use \"Session Startup\"/\"Red Lines\" with legacy fallback to \"Every Session\"/\"Safety\"; set to [] to disable reinjection entirely.", "hasChildren": true @@ -1115,16 +1032,10 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "off", - "async", - "await" - ], + "enumValues": ["off", "async", "await"], "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Compaction Post-Index Sync", "help": "Controls post-compaction session memory reindex mode: \"off\", \"async\", or \"await\" (default: \"async\"). Use \"await\" for strongest freshness, \"async\" for lower compaction latency, and \"off\" only when session-memory sync is handled elsewhere.", "hasChildren": false @@ -1136,9 +1047,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Compaction Quality Guard", "help": "Optional quality-audit retry settings for safeguard compaction summaries. Leave this disabled unless you explicitly want summary audits and one-shot regeneration on failed checks.", "hasChildren": true @@ -1150,9 +1059,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Compaction Quality Guard Enabled", "help": "Enables summary quality audits and regeneration retries for safeguard compaction. Default: false, so safeguard mode alone does not turn on retry behavior.", "hasChildren": false @@ -1164,9 +1071,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Compaction Quality Guard Max Retries", "help": "Maximum number of regeneration retries after a failed safeguard summary quality audit. Use small values to bound extra latency and token cost.", "hasChildren": false @@ -1178,9 +1083,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Compaction Preserve Recent Turns", "help": "Number of most recent user/assistant turns kept verbatim outside safeguard summarization (default: 3). Raise this to preserve exact recent dialogue context, or lower it to maximize compaction savings.", "hasChildren": false @@ -1192,10 +1095,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "auth", - "security" - ], + "tags": ["auth", "security"], "label": "Compaction Reserve Tokens", "help": "Token headroom reserved for reply generation and tool output after compaction runs. Use higher reserves for verbose/tool-heavy sessions, and lower reserves when maximizing retained history matters more.", "hasChildren": false @@ -1207,10 +1107,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "auth", - "security" - ], + "tags": ["auth", "security"], "label": "Compaction Reserve Token Floor", "help": "Minimum floor enforced for reserveTokens in Pi compaction paths (0 disables the floor guard). Use a non-zero floor to avoid over-aggressive compression under fluctuating token estimates.", "hasChildren": false @@ -1222,9 +1119,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Compaction Timeout (Seconds)", "help": "Maximum time in seconds allowed for a single compaction operation before it is aborted (default: 900). Increase this for very large sessions that need more time to summarize, or decrease it to fail faster on unresponsive models.", "hasChildren": false @@ -1446,9 +1341,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Embedded Pi", "help": "Embedded Pi runner hardening controls for how workspace-local Pi settings are trusted and applied in OpenClaw sessions.", "hasChildren": true @@ -1460,9 +1353,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Embedded Pi Project Settings Policy", "help": "How embedded Pi handles workspace-local `.pi/config/settings.json`: \"sanitize\" (default) strips shellPath/shellCommandPrefix, \"ignore\" disables project settings entirely, and \"trusted\" applies project settings as-is.", "hasChildren": false @@ -1474,9 +1365,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Envelope Elapsed", "help": "Include elapsed time in message envelopes (\"on\" or \"off\").", "hasChildren": false @@ -1488,9 +1377,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Envelope Timestamp", "help": "Include absolute timestamps in message envelopes (\"on\" or \"off\").", "hasChildren": false @@ -1502,9 +1389,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Envelope Timezone", "help": "Timezone for message envelopes (\"utc\", \"local\", \"user\", or an IANA timezone string).", "hasChildren": false @@ -1586,11 +1471,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "automation", - "storage" - ], + "tags": ["access", "automation", "storage"], "label": "Heartbeat Direct Policy", "help": "Controls whether heartbeat delivery may target direct/DM chats: \"allow\" (default) permits DM delivery and \"block\" suppresses direct-target sends.", "hasChildren": false @@ -1672,9 +1553,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation" - ], + "tags": ["automation"], "label": "Heartbeat Suppress Tool Error Warnings", "help": "Suppress tool error warning payloads during heartbeat runs.", "hasChildren": false @@ -1686,9 +1565,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation" - ], + "tags": ["automation"], "help": "Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.", "hasChildren": false }, @@ -1719,9 +1596,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Human Delay Max (ms)", "help": "Maximum delay in ms for custom humanDelay (default: 2500).", "hasChildren": false @@ -1733,9 +1608,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Human Delay Min (ms)", "help": "Minimum delay in ms for custom humanDelay (default: 800).", "hasChildren": false @@ -1747,9 +1620,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Human Delay Mode", "help": "Delay style for block replies (\"off\", \"natural\", \"custom\").", "hasChildren": false @@ -1761,10 +1632,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "performance" - ], + "tags": ["media", "performance"], "label": "Image Max Dimension (px)", "help": "Max image side length in pixels when sanitizing transcript/tool-result image payloads (default: 1200).", "hasChildren": false @@ -1772,10 +1640,7 @@ { "path": "agents.defaults.imageModel", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -1789,11 +1654,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "models", - "reliability" - ], + "tags": ["media", "models", "reliability"], "label": "Image Model Fallbacks", "help": "Ordered fallback image models (provider/model).", "hasChildren": true @@ -1815,10 +1676,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "models" - ], + "tags": ["media", "models"], "label": "Image Model", "help": "Optional image model (provider/model) used when the primary model lacks image input.", "hasChildren": false @@ -1850,9 +1708,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Memory Search", "help": "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", "hasChildren": true @@ -1874,9 +1730,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Memory Search Embedding Cache", "help": "Caches computed chunk embeddings in SQLite so reindexing and incremental updates run faster (default: true). Keep this enabled unless investigating cache correctness or minimizing disk usage.", "hasChildren": false @@ -1888,10 +1742,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage" - ], + "tags": ["performance", "storage"], "label": "Memory Search Embedding Cache Max Entries", "help": "Sets a best-effort upper bound on cached embeddings kept in SQLite for memory search. Use this when controlling disk growth matters more than peak reindex speed.", "hasChildren": false @@ -1913,9 +1764,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Memory Chunk Overlap Tokens", "help": "Token overlap between adjacent memory chunks to preserve context continuity near split boundaries. Use modest overlap to reduce boundary misses without inflating index size too aggressively.", "hasChildren": false @@ -1927,10 +1776,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "auth", - "security" - ], + "tags": ["auth", "security"], "label": "Memory Chunk Tokens", "help": "Chunk size in tokens used when splitting memory sources before embedding/indexing. Increase for broader context per chunk, or lower to improve precision on pinpoint lookups.", "hasChildren": false @@ -1942,9 +1788,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable Memory Search", "help": "Master toggle for memory search indexing and retrieval behavior on this agent profile. Keep enabled for semantic recall, and disable when you want fully stateless responses.", "hasChildren": false @@ -1966,11 +1810,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced", - "security", - "storage" - ], + "tags": ["advanced", "security", "storage"], "label": "Memory Search Session Index (Experimental)", "help": "Indexes session transcripts into memory search so responses can reference prior chat turns. Keep this off unless transcript recall is needed, because indexing cost and storage usage both increase.", "hasChildren": false @@ -1982,9 +1822,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Extra Memory Paths", "help": "Adds extra directories or .md files to the memory index beyond default memory files. Use this when key reference docs live elsewhere in your repo; when multimodal memory is enabled, matching image/audio files under these paths are also eligible for indexing.", "hasChildren": true @@ -2006,9 +1844,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "reliability" - ], + "tags": ["reliability"], "label": "Memory Search Fallback", "help": "Backup provider used when primary embeddings fail: \"openai\", \"gemini\", \"voyage\", \"mistral\", \"ollama\", \"local\", or \"none\". Set a real fallback for production reliability; use \"none\" only if you prefer explicit failures.", "hasChildren": false @@ -2040,9 +1876,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Local Embedding Model Path", "help": "Specifies the local embedding model source for local memory search, such as a GGUF file path or `hf:` URI. Use this only when provider is `local`, and verify model compatibility before large index rebuilds.", "hasChildren": false @@ -2054,9 +1888,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models" - ], + "tags": ["models"], "label": "Memory Search Model", "help": "Embedding model override used by the selected memory provider when a non-default model is required. Set this only when you need explicit recall quality/cost tuning beyond provider defaults.", "hasChildren": false @@ -2068,9 +1900,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Memory Search Multimodal", "help": "Optional multimodal memory settings for indexing image and audio files from configured extra paths. Keep this off unless your embedding model explicitly supports cross-modal embeddings, and set `memorySearch.fallback` to \"none\" while it is enabled. Matching files are uploaded to the configured remote embedding provider during indexing.", "hasChildren": true @@ -2082,9 +1912,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable Memory Search Multimodal", "help": "Enables image/audio memory indexing from extraPaths. This currently requires Gemini embedding-2, keeps the default memory roots Markdown-only, disables memory-search fallback providers, and uploads matching binary content to the configured remote embedding provider.", "hasChildren": false @@ -2096,10 +1924,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage" - ], + "tags": ["performance", "storage"], "label": "Memory Search Multimodal Max File Bytes", "help": "Sets the maximum bytes allowed per multimodal file before it is skipped during memory indexing. Use this to cap upload cost and indexing latency, or raise it for short high-quality audio clips.", "hasChildren": false @@ -2111,9 +1936,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Memory Search Multimodal Modalities", "help": "Selects which multimodal file types are indexed from extraPaths: \"image\", \"audio\", or \"all\". Keep this narrow to avoid indexing large binary corpora unintentionally.", "hasChildren": true @@ -2135,9 +1958,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Memory Search Output Dimensionality", "help": "Gemini embedding-2 only: chooses the output vector size for memory embeddings. Use 768, 1536, or 3072 (default), and expect a full reindex when you change it because stored vector dimensions must stay consistent.", "hasChildren": false @@ -2149,9 +1970,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Memory Search Provider", "help": "Selects the embedding backend used to build/query memory vectors: \"openai\", \"gemini\", \"voyage\", \"mistral\", \"ollama\", or \"local\". Keep your most reliable provider here and configure fallback for resilience.", "hasChildren": false @@ -2183,9 +2002,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Memory Search Hybrid Candidate Multiplier", "help": "Expands the candidate pool before reranking (default: 4). Raise this for better recall on noisy corpora, but expect more compute and slightly slower searches.", "hasChildren": false @@ -2197,9 +2014,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Memory Search Hybrid", "help": "Combines BM25 keyword matching with vector similarity for better recall on mixed exact + semantic queries. Keep enabled unless you are isolating ranking behavior for troubleshooting.", "hasChildren": false @@ -2221,9 +2036,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Memory Search MMR Re-ranking", "help": "Adds MMR reranking to diversify results and reduce near-duplicate snippets in a single answer window. Enable when recall looks repetitive; keep off for strict score ordering.", "hasChildren": false @@ -2235,9 +2048,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Memory Search MMR Lambda", "help": "Sets MMR relevance-vs-diversity balance (0 = most diverse, 1 = most relevant, default: 0.7). Lower values reduce repetition; higher values keep tightly relevant but may duplicate.", "hasChildren": false @@ -2259,9 +2070,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Memory Search Temporal Decay", "help": "Applies recency decay so newer memory can outrank older memory when scores are close. Enable when timeliness matters; keep off for timeless reference knowledge.", "hasChildren": false @@ -2273,9 +2082,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Memory Search Temporal Decay Half-life (Days)", "help": "Controls how fast older memory loses rank when temporal decay is enabled (half-life in days, default: 30). Lower values prioritize recent context more aggressively.", "hasChildren": false @@ -2287,9 +2094,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Memory Search Text Weight", "help": "Controls how strongly BM25 keyword relevance influences hybrid ranking (0-1). Increase for exact-term matching; decrease when semantic matches should rank higher.", "hasChildren": false @@ -2301,9 +2106,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Memory Search Vector Weight", "help": "Controls how strongly semantic similarity influences hybrid ranking (0-1). Increase when paraphrase matching matters more than exact terms; decrease for stricter keyword emphasis.", "hasChildren": false @@ -2315,9 +2118,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Memory Search Max Results", "help": "Maximum number of memory hits returned from search before downstream reranking and prompt injection. Raise for broader recall, or lower for tighter prompts and faster responses.", "hasChildren": false @@ -2329,9 +2130,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Memory Search Min Score", "help": "Minimum relevance score threshold for including memory results in final recall output. Increase to reduce weak/noisy matches, or lower when you need more permissive retrieval.", "hasChildren": false @@ -2349,17 +2148,11 @@ { "path": "agents.defaults.memorySearch.remote.apiKey", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "security" - ], + "tags": ["auth", "security"], "label": "Remote Embedding API Key", "help": "Supplies a dedicated API key for remote embedding calls used by memory indexing and query-time embeddings. Use this when memory embeddings should use different credentials than global defaults or environment variables.", "hasChildren": true @@ -2401,9 +2194,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Remote Embedding Base URL", "help": "Overrides the embedding API endpoint, such as an OpenAI-compatible proxy or custom Gemini base URL. Use this only when routing through your own gateway or vendor endpoint; keep provider defaults otherwise.", "hasChildren": false @@ -2425,9 +2216,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Remote Batch Concurrency", "help": "Limits how many embedding batch jobs run at the same time during indexing (default: 2). Increase carefully for faster bulk indexing, but watch provider rate limits and queue errors.", "hasChildren": false @@ -2439,9 +2228,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Remote Batch Embedding Enabled", "help": "Enables provider batch APIs for embedding jobs when supported (OpenAI/Gemini), improving throughput on larger index runs. Keep this enabled unless debugging provider batch failures or running very small workloads.", "hasChildren": false @@ -2453,9 +2240,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Remote Batch Poll Interval (ms)", "help": "Controls how often the system polls provider APIs for batch job status in milliseconds (default: 2000). Use longer intervals to reduce API chatter, or shorter intervals for faster completion detection.", "hasChildren": false @@ -2467,9 +2252,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Remote Batch Timeout (min)", "help": "Sets the maximum wait time for a full embedding batch operation in minutes (default: 60). Increase for very large corpora or slower providers, and lower it to fail fast in automation-heavy flows.", "hasChildren": false @@ -2481,9 +2264,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Remote Batch Wait for Completion", "help": "Waits for batch embedding jobs to fully finish before the indexing operation completes. Keep this enabled for deterministic indexing state; disable only if you accept delayed consistency.", "hasChildren": false @@ -2495,9 +2276,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Remote Embedding Headers", "help": "Adds custom HTTP headers to remote embedding requests, merged with provider defaults. Use this for proxy auth and tenant routing headers, and keep values minimal to avoid leaking sensitive metadata.", "hasChildren": true @@ -2519,9 +2298,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Memory Search Sources", "help": "Chooses which sources are indexed: \"memory\" reads MEMORY.md + memory files, and \"sessions\" includes transcript history. Keep [\"memory\"] unless you need recall from prior chat transcripts.", "hasChildren": true @@ -2563,9 +2340,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Memory Search Index Path", "help": "Sets where the SQLite memory index is stored on disk for each agent. Keep the default `~/.openclaw/memory/{agentId}.sqlite` unless you need custom storage placement or backup policy alignment.", "hasChildren": false @@ -2587,9 +2362,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Memory Search Vector Index", "help": "Enables the sqlite-vec extension used for vector similarity queries in memory search (default: true). Keep this enabled for normal semantic recall; disable only for debugging or fallback-only operation.", "hasChildren": false @@ -2601,9 +2374,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Memory Search Vector Extension Path", "help": "Overrides the auto-discovered sqlite-vec extension library path (`.dylib`, `.so`, or `.dll`). Use this when your runtime cannot find sqlite-vec automatically or you pin a known-good build.", "hasChildren": false @@ -2635,9 +2406,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Index on Search (Lazy)", "help": "Uses lazy sync by scheduling reindex on search after content changes are detected. Keep enabled for lower idle overhead, or disable if you require pre-synced indexes before any query.", "hasChildren": false @@ -2649,10 +2418,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation", - "storage" - ], + "tags": ["automation", "storage"], "label": "Index on Session Start", "help": "Triggers a memory index sync when a session starts so early turns see fresh memory content. Keep enabled when startup freshness matters more than initial turn latency.", "hasChildren": false @@ -2674,9 +2440,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Delta Bytes", "help": "Requires at least this many newly appended bytes before session transcript changes trigger reindex (default: 100000). Increase to reduce frequent small reindexes, or lower for faster transcript freshness.", "hasChildren": false @@ -2688,9 +2452,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Delta Messages", "help": "Requires at least this many appended transcript messages before reindex is triggered (default: 50). Lower this for near-real-time transcript recall, or raise it to reduce indexing churn.", "hasChildren": false @@ -2702,9 +2464,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Force Reindex After Compaction", "help": "Forces a session memory-search reindex after compaction-triggered transcript updates (default: true). Keep enabled when compacted summaries must be immediately searchable, or disable to reduce write-time indexing pressure.", "hasChildren": false @@ -2716,9 +2476,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Watch Memory Files", "help": "Watches memory files and schedules index updates from file-change events (chokidar). Enable for near-real-time freshness; disable on very large workspaces if watch churn is too noisy.", "hasChildren": false @@ -2730,10 +2488,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation", - "performance" - ], + "tags": ["automation", "performance"], "label": "Memory Watch Debounce (ms)", "help": "Debounce window in milliseconds for coalescing rapid file-watch events before reindex runs. Increase to reduce churn on frequently-written files, or lower for faster freshness.", "hasChildren": false @@ -2741,10 +2496,7 @@ { "path": "agents.defaults.model", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -2758,10 +2510,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models", - "reliability" - ], + "tags": ["models", "reliability"], "label": "Model Fallbacks", "help": "Ordered fallback models (provider/model). Used when the primary model fails.", "hasChildren": true @@ -2783,9 +2532,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models" - ], + "tags": ["models"], "label": "Primary Model", "help": "Primary model (provider/model).", "hasChildren": false @@ -2797,9 +2544,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models" - ], + "tags": ["models"], "label": "Models", "help": "Configured model catalog (keys are full provider/model IDs).", "hasChildren": true @@ -2860,9 +2605,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "PDF Max Size (MB)", "help": "Maximum PDF file size in megabytes for the PDF tool (default: 10).", "hasChildren": false @@ -2874,9 +2617,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "PDF Max Pages", "help": "Maximum number of PDF pages to process for the PDF tool (default: 20).", "hasChildren": false @@ -2884,10 +2625,7 @@ { "path": "agents.defaults.pdfModel", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -2901,9 +2639,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "reliability" - ], + "tags": ["reliability"], "label": "PDF Model Fallbacks", "help": "Ordered fallback PDF models (provider/model).", "hasChildren": true @@ -2925,9 +2661,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "PDF Model", "help": "Optional PDF model (provider/model) for the PDF analysis tool. Defaults to imageModel, then session model.", "hasChildren": false @@ -2939,9 +2673,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Repo Root", "help": "Optional repository root shown in the system prompt runtime line (overrides auto-detect).", "hasChildren": false @@ -3033,9 +2765,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Sandbox Browser CDP Source Port Range", "help": "Optional CIDR allowlist for container-edge CDP ingress (for example 172.21.0.1/32).", "hasChildren": false @@ -3097,9 +2827,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Sandbox Browser Network", "help": "Docker network for sandbox browser containers (default: openclaw-sandbox-browser). Avoid bridge if you need stricter isolation.", "hasChildren": false @@ -3211,12 +2939,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "advanced", - "security", - "storage" - ], + "tags": ["access", "advanced", "security", "storage"], "label": "Sandbox Docker Allow Container Namespace Join", "help": "DANGEROUS break-glass override that allows sandbox Docker network mode container:. This joins another container namespace and weakens sandbox isolation.", "hasChildren": false @@ -3314,10 +3037,7 @@ { "path": "agents.defaults.sandbox.docker.memory", "kind": "core", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -3327,10 +3047,7 @@ { "path": "agents.defaults.sandbox.docker.memorySwap", "kind": "core", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -3419,11 +3136,7 @@ { "path": "agents.defaults.sandbox.docker.ulimits.*", "kind": "core", - "type": [ - "number", - "object", - "string" - ], + "type": ["number", "object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -3633,10 +3346,7 @@ { "path": "agents.defaults.subagents.model", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -3770,9 +3480,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Workspace", "help": "Default workspace path exposed to agent runtime tools for filesystem context and repo-aware behavior. Set this explicitly when running from wrappers so path resolution stays deterministic.", "hasChildren": false @@ -3784,9 +3492,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Agent List", "help": "Explicit list of configured agents with IDs and optional overrides for model, tools, identity, and workspace. Keep IDs stable over time so bindings, approvals, and session routing remain deterministic.", "hasChildren": true @@ -3938,11 +3644,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "automation", - "storage" - ], + "tags": ["access", "automation", "storage"], "label": "Heartbeat Direct Policy", "help": "Per-agent override for heartbeat direct/DM delivery policy; use \"block\" for agents that should only send heartbeat alerts to non-DM destinations.", "hasChildren": false @@ -4024,9 +3726,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation" - ], + "tags": ["automation"], "label": "Agent Heartbeat Suppress Tool Error Warnings", "help": "Suppress tool error warning payloads during heartbeat runs.", "hasChildren": false @@ -4038,9 +3738,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation" - ], + "tags": ["automation"], "help": "Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.", "hasChildren": false }, @@ -4121,9 +3819,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Identity Avatar", "help": "Agent avatar (workspace-relative path, http(s) URL, or data URI).", "hasChildren": false @@ -4551,17 +4247,11 @@ { "path": "agents.list.*.memorySearch.remote.apiKey", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "security" - ], + "tags": ["auth", "security"], "hasChildren": true }, { @@ -4867,10 +4557,7 @@ { "path": "agents.list.*.model", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -4943,9 +4630,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Agent Runtime", "help": "Optional runtime descriptor for this agent. Use embedded for default OpenClaw execution or acp for external ACP harness defaults.", "hasChildren": true @@ -4957,9 +4642,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Agent ACP Runtime", "help": "ACP runtime defaults for this agent when runtime.type=acp. Binding-level ACP overrides still take precedence per conversation.", "hasChildren": true @@ -4971,9 +4654,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Agent ACP Harness Agent", "help": "Optional ACP harness agent id to use for this OpenClaw agent (for example codex, claude).", "hasChildren": false @@ -4985,9 +4666,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Agent ACP Backend", "help": "Optional ACP backend override for this agent's ACP sessions (falls back to global acp.backend).", "hasChildren": false @@ -4999,9 +4678,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Agent ACP Working Directory", "help": "Optional default working directory for this agent's ACP sessions.", "hasChildren": false @@ -5011,15 +4688,10 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "persistent", - "oneshot" - ], + "enumValues": ["persistent", "oneshot"], "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Agent ACP Mode", "help": "Optional ACP session mode default for this agent (persistent or oneshot).", "hasChildren": false @@ -5031,9 +4703,7 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Agent Runtime Type", "help": "Runtime type for this agent: \"embedded\" (default OpenClaw runtime) or \"acp\" (ACP harness defaults).", "hasChildren": false @@ -5125,9 +4795,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Agent Sandbox Browser CDP Source Port Range", "help": "Per-agent override for CDP source CIDR allowlist.", "hasChildren": false @@ -5189,9 +4857,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Agent Sandbox Browser Network", "help": "Per-agent override for sandbox browser Docker network.", "hasChildren": false @@ -5303,12 +4969,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "advanced", - "security", - "storage" - ], + "tags": ["access", "advanced", "security", "storage"], "label": "Agent Sandbox Docker Allow Container Namespace Join", "help": "Per-agent DANGEROUS override for container namespace joins in sandbox Docker network mode.", "hasChildren": false @@ -5406,10 +5067,7 @@ { "path": "agents.list.*.sandbox.docker.memory", "kind": "core", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -5419,10 +5077,7 @@ { "path": "agents.list.*.sandbox.docker.memorySwap", "kind": "core", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -5511,11 +5166,7 @@ { "path": "agents.list.*.sandbox.docker.ulimits.*", "kind": "core", - "type": [ - "number", - "object", - "string" - ], + "type": ["number", "object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -5659,9 +5310,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Agent Skill Filter", "help": "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", "hasChildren": true @@ -5709,10 +5358,7 @@ { "path": "agents.list.*.subagents.model", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -5796,9 +5442,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Agent Tool Allowlist Additions", "help": "Per-agent additive allowlist for tools on top of global and profile policy. Keep narrow to avoid accidental privilege expansion on specialized agents.", "hasChildren": true @@ -5820,9 +5464,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Agent Tool Policy by Provider", "help": "Per-agent provider-specific tool policy overrides for channel-scoped capability control. Use this when a single agent needs tighter restrictions on one provider than others.", "hasChildren": true @@ -5960,10 +5602,7 @@ { "path": "agents.list.*.tools.elevated.allowFrom.*.*", "kind": "core", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -6055,11 +5694,7 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "off", - "on-miss", - "always" - ], + "enumValues": ["off", "on-miss", "always"], "deprecated": false, "sensitive": false, "tags": [], @@ -6090,11 +5725,7 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "sandbox", - "gateway", - "node" - ], + "enumValues": ["sandbox", "gateway", "node"], "deprecated": false, "sensitive": false, "tags": [], @@ -6275,11 +5906,7 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "deny", - "allowlist", - "full" - ], + "enumValues": ["deny", "allowlist", "full"], "deprecated": false, "sensitive": false, "tags": [], @@ -6422,9 +6049,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Agent Tool Profile", "help": "Per-agent override for tool profile selection when one agent needs a different capability baseline. Use this sparingly so policy differences across agents stay intentional and reviewable.", "hasChildren": false @@ -6526,9 +6151,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Approvals", "help": "Approval routing controls for forwarding exec approval requests to chat destinations outside the originating session. Keep this disabled unless operators need explicit out-of-band approval visibility.", "hasChildren": true @@ -6540,9 +6163,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Exec Approval Forwarding", "help": "Groups exec-approval forwarding behavior including enablement, routing mode, filters, and explicit targets. Configure here when approval prompts must reach operational channels instead of only the origin thread.", "hasChildren": true @@ -6554,9 +6175,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Approval Agent Filter", "help": "Optional allowlist of agent IDs eligible for forwarded approvals, for example `[\"primary\", \"ops-agent\"]`. Use this to limit forwarding blast radius and avoid notifying channels for unrelated agents.", "hasChildren": true @@ -6578,9 +6197,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Forward Exec Approvals", "help": "Enables forwarding of exec approval requests to configured delivery destinations (default: false). Keep disabled in low-risk setups and enable only when human approval responders need channel-visible prompts.", "hasChildren": false @@ -6592,9 +6209,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Approval Forwarding Mode", "help": "Controls where approval prompts are sent: \"session\" uses origin chat, \"targets\" uses configured targets, and \"both\" sends to both paths. Use \"session\" as baseline and expand only when operational workflow requires redundancy.", "hasChildren": false @@ -6606,9 +6221,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Approval Session Filter", "help": "Optional session-key filters matched as substring or regex-style patterns, for example `[\"discord:\", \"^agent:ops:\"]`. Use narrow patterns so only intended approval contexts are forwarded to shared destinations.", "hasChildren": true @@ -6630,9 +6243,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Approval Forwarding Targets", "help": "Explicit delivery targets used when forwarding mode includes targets, each with channel and destination details. Keep target lists least-privilege and validate each destination before enabling broad forwarding.", "hasChildren": true @@ -6654,9 +6265,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Approval Target Account ID", "help": "Optional account selector for multi-account channel setups when approvals must route through a specific account context. Use this only when the target channel has multiple configured identities.", "hasChildren": false @@ -6668,9 +6277,7 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Approval Target Channel", "help": "Channel/provider ID used for forwarded approval delivery, such as discord, slack, or a plugin channel id. Use valid channel IDs only so approvals do not silently fail due to unknown routes.", "hasChildren": false @@ -6678,16 +6285,11 @@ { "path": "approvals.exec.targets.*.threadId", "kind": "core", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Approval Target Thread ID", "help": "Optional thread/topic target for channels that support threaded delivery of forwarded approvals. Use this to keep approval traffic contained in operational threads instead of main channels.", "hasChildren": false @@ -6699,9 +6301,7 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Approval Target Destination", "help": "Destination identifier inside the target channel (channel ID, user ID, or thread root depending on provider). Verify semantics per provider because destination format differs across channel integrations.", "hasChildren": false @@ -6713,9 +6313,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Audio", "help": "Global audio ingestion settings used before higher-level tools process speech or media content. Configure this when you need deterministic transcription behavior for voice notes and clips.", "hasChildren": true @@ -6727,9 +6325,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media" - ], + "tags": ["media"], "label": "Audio Transcription", "help": "Command-based transcription settings for converting audio files into text before agent handling. Keep a simple, deterministic command path here so failures are easy to diagnose in logs.", "hasChildren": true @@ -6741,9 +6337,7 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": [ - "media" - ], + "tags": ["media"], "label": "Audio Transcription Command", "help": "Executable + args used to transcribe audio (first token must be a safe binary/path), for example `[\"whisper-cli\", \"--model\", \"small\", \"{input}\"]`. Prefer a pinned command so runtime environments behave consistently.", "hasChildren": true @@ -6765,10 +6359,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "performance" - ], + "tags": ["media", "performance"], "label": "Audio Transcription Timeout (sec)", "help": "Maximum time allowed for the transcription command to finish before it is aborted. Increase this for longer recordings, and keep it tight in latency-sensitive deployments.", "hasChildren": false @@ -6780,9 +6371,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Auth", "help": "Authentication profile root used for multi-profile provider credentials and cooldown-based failover ordering. Keep profiles minimal and explicit so automatic failover behavior stays auditable.", "hasChildren": true @@ -6794,10 +6383,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "auth" - ], + "tags": ["access", "auth"], "label": "Auth Cooldowns", "help": "Cooldown/backoff controls for temporary profile suppression after billing-related failures and retry windows. Use these to prevent rapid re-selection of profiles that are still blocked.", "hasChildren": true @@ -6809,11 +6395,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "auth", - "reliability" - ], + "tags": ["access", "auth", "reliability"], "label": "Billing Backoff (hours)", "help": "Base backoff (hours) when a profile fails due to billing/insufficient credits (default: 5).", "hasChildren": false @@ -6825,11 +6407,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "auth", - "reliability" - ], + "tags": ["access", "auth", "reliability"], "label": "Billing Backoff Overrides", "help": "Optional per-provider overrides for billing backoff (hours).", "hasChildren": true @@ -6851,11 +6429,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "auth", - "performance" - ], + "tags": ["access", "auth", "performance"], "label": "Billing Backoff Cap (hours)", "help": "Cap (hours) for billing backoff (default: 24).", "hasChildren": false @@ -6867,10 +6441,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "auth" - ], + "tags": ["access", "auth"], "label": "Failover Window (hours)", "help": "Failure window (hours) for backoff counters (default: 24).", "hasChildren": false @@ -6882,10 +6453,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "auth" - ], + "tags": ["access", "auth"], "label": "Auth Profile Order", "help": "Ordered auth profile IDs per provider (used for automatic failover).", "hasChildren": true @@ -6917,11 +6485,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "auth", - "storage" - ], + "tags": ["access", "auth", "storage"], "label": "Auth Profiles", "help": "Named auth profiles (provider + mode + optional email).", "hasChildren": true @@ -6973,9 +6537,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Bindings", "help": "Top-level binding rules for routing and persistent ACP conversation ownership. Use type=route for normal routing and type=acp for persistent ACP harness bindings.", "hasChildren": true @@ -6997,9 +6559,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "ACP Binding Overrides", "help": "Optional per-binding ACP overrides for bindings[].type=acp. This layer overrides agents.list[].runtime.acp defaults for the matched conversation.", "hasChildren": true @@ -7011,9 +6571,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "ACP Binding Backend", "help": "ACP backend override for this binding (falls back to agent runtime ACP backend, then global acp.backend).", "hasChildren": false @@ -7025,9 +6583,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "ACP Binding Working Directory", "help": "Working directory override for ACP sessions created from this binding.", "hasChildren": false @@ -7039,9 +6595,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "ACP Binding Label", "help": "Human-friendly label for ACP status/diagnostics in this bound conversation.", "hasChildren": false @@ -7051,15 +6605,10 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "persistent", - "oneshot" - ], + "enumValues": ["persistent", "oneshot"], "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "ACP Binding Mode", "help": "ACP session mode override for this binding (persistent or oneshot).", "hasChildren": false @@ -7071,9 +6620,7 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Binding Agent ID", "help": "Target agent ID that receives traffic when the corresponding binding match rule is satisfied. Use valid configured agent IDs only so routing does not fail at runtime.", "hasChildren": false @@ -7095,9 +6642,7 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Binding Match Rule", "help": "Match rule object for deciding when a binding applies, including channel and optional account/peer constraints. Keep rules narrow to avoid accidental agent takeover across contexts.", "hasChildren": true @@ -7109,9 +6654,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Binding Account ID", "help": "Optional account selector for multi-account channel setups so the binding applies only to one identity. Use this when account scoping is required for the route and leave unset otherwise.", "hasChildren": false @@ -7123,9 +6666,7 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Binding Channel", "help": "Channel/provider identifier this binding applies to, such as `telegram`, `discord`, or a plugin channel ID. Use the configured channel key exactly so binding evaluation works reliably.", "hasChildren": false @@ -7137,9 +6678,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Binding Guild ID", "help": "Optional Discord-style guild/server ID constraint for binding evaluation in multi-server deployments. Use this when the same peer identifiers can appear across different guilds.", "hasChildren": false @@ -7151,9 +6690,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Binding Peer Match", "help": "Optional peer matcher for specific conversations including peer kind and peer id. Use this when only one direct/group/channel target should be pinned to an agent.", "hasChildren": true @@ -7165,9 +6702,7 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Binding Peer ID", "help": "Conversation identifier used with peer matching, such as a chat ID, channel ID, or group ID from the provider. Keep this exact to avoid silent non-matches.", "hasChildren": false @@ -7179,9 +6714,7 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Binding Peer Kind", "help": "Peer conversation type: \"direct\", \"group\", \"channel\", or legacy \"dm\" (deprecated alias for direct). Prefer \"direct\" for new configs and keep kind aligned with channel semantics.", "hasChildren": false @@ -7193,9 +6726,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Binding Roles", "help": "Optional role-based filter list used by providers that attach roles to chat context. Use this to route privileged or operational role traffic to specialized agents.", "hasChildren": true @@ -7217,9 +6748,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Binding Team ID", "help": "Optional team/workspace ID constraint used by providers that scope chats under teams. Add this when you need bindings isolated to one workspace context.", "hasChildren": false @@ -7231,9 +6760,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Binding Type", "help": "Binding kind. Use \"route\" (or omit for legacy route entries) for normal routing, and \"acp\" for persistent ACP conversation bindings.", "hasChildren": false @@ -7245,9 +6772,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Broadcast", "help": "Broadcast routing map for sending the same outbound message to multiple peer IDs per source conversation. Keep this minimal and audited because one source can fan out to many destinations.", "hasChildren": true @@ -7259,9 +6784,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Broadcast Destination List", "help": "Per-source broadcast destination list where each key is a source peer ID and the value is an array of destination peer IDs. Keep lists intentional to avoid accidental message amplification.", "hasChildren": true @@ -7281,15 +6804,10 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "parallel", - "sequential" - ], + "enumValues": ["parallel", "sequential"], "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Broadcast Strategy", "help": "Delivery order for broadcast fan-out: \"parallel\" sends to all targets concurrently, while \"sequential\" sends one-by-one. Use \"parallel\" for speed and \"sequential\" for stricter ordering/backpressure control.", "hasChildren": false @@ -7301,9 +6819,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Browser", "help": "Browser runtime controls for local or remote CDP attachment, profile routing, and screenshot/snapshot behavior. Keep defaults unless your automation workflow requires custom browser transport settings.", "hasChildren": true @@ -7315,9 +6831,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Browser Attach-only Mode", "help": "Restricts browser mode to attach-only behavior without starting local browser processes. Use this when all browser sessions are externally managed by a remote CDP provider.", "hasChildren": false @@ -7329,9 +6843,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Browser CDP Port Range Start", "help": "Starting local CDP port used for auto-allocated browser profile ports. Increase this when host-level port defaults conflict with other local services.", "hasChildren": false @@ -7343,9 +6855,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Browser CDP URL", "help": "Remote CDP websocket URL used to attach to an externally managed browser instance. Use this for centralized browser hosts and keep URL access restricted to trusted network paths.", "hasChildren": false @@ -7357,9 +6867,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Browser Accent Color", "help": "Default accent color used for browser profile/UI cues where colored identity hints are displayed. Use consistent colors to help operators identify active browser profile context quickly.", "hasChildren": false @@ -7371,9 +6879,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Browser Default Profile", "help": "Default browser profile name selected when callers do not explicitly choose a profile. Use a stable low-privilege profile as the default to reduce accidental cross-context state use.", "hasChildren": false @@ -7385,9 +6891,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Browser Enabled", "help": "Enables browser capability wiring in the gateway so browser tools and CDP-driven workflows can run. Disable when browser automation is not needed to reduce surface area and startup work.", "hasChildren": false @@ -7399,9 +6903,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Browser Evaluate Enabled", "help": "Enables browser-side evaluate helpers for runtime script evaluation capabilities where supported. Keep disabled unless your workflows require evaluate semantics beyond snapshots/navigation.", "hasChildren": false @@ -7413,9 +6915,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Browser Executable Path", "help": "Explicit browser executable path when auto-discovery is insufficient for your host environment. Use absolute stable paths so launch behavior stays deterministic across restarts.", "hasChildren": false @@ -7447,9 +6947,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Browser Headless Mode", "help": "Forces browser launch in headless mode when the local launcher starts browser instances. Keep headless enabled for server environments and disable only when visible UI debugging is required.", "hasChildren": false @@ -7461,9 +6959,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Browser No-Sandbox Mode", "help": "Disables Chromium sandbox isolation flags for environments where sandboxing fails at runtime. Keep this off whenever possible because process isolation protections are reduced.", "hasChildren": false @@ -7475,9 +6971,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Browser Profiles", "help": "Named browser profile connection map used for explicit routing to CDP ports or URLs with optional metadata. Keep profile names consistent and avoid overlapping endpoint definitions.", "hasChildren": true @@ -7499,9 +6993,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Browser Profile Attach-only Mode", "help": "Per-profile attach-only override that skips local browser launch and only attaches to an existing CDP endpoint. Useful when one profile is externally managed but others are locally launched.", "hasChildren": false @@ -7513,9 +7005,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Browser Profile CDP Port", "help": "Per-profile local CDP port used when connecting to browser instances by port instead of URL. Use unique ports per profile to avoid connection collisions.", "hasChildren": false @@ -7527,9 +7017,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Browser Profile CDP URL", "help": "Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.", "hasChildren": false @@ -7541,9 +7029,7 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Browser Profile Accent Color", "help": "Per-profile accent color for visual differentiation in dashboards and browser-related UI hints. Use distinct colors for high-signal operator recognition of active profiles.", "hasChildren": false @@ -7555,9 +7041,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Browser Profile Driver", "help": "Per-profile browser driver mode: \"openclaw\" (or legacy \"clawd\") or \"extension\" depending on connection/runtime strategy. Use the driver that matches your browser control stack to avoid protocol mismatches.", "hasChildren": false @@ -7569,9 +7053,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Browser Relay Bind Address", "help": "Bind IP address for the Chrome extension relay listener. Leave unset for loopback-only access, or set an explicit non-loopback IP such as 0.0.0.0 only when the relay must be reachable across network namespaces (for example WSL2) and the surrounding network is already trusted.", "hasChildren": false @@ -7583,9 +7065,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Remote CDP Handshake Timeout (ms)", "help": "Timeout in milliseconds for post-connect CDP handshake readiness checks against remote browser targets. Raise this for slow-start remote browsers and lower to fail fast in automation loops.", "hasChildren": false @@ -7597,9 +7077,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Remote CDP Timeout (ms)", "help": "Timeout in milliseconds for connecting to a remote CDP endpoint before failing the browser attach attempt. Increase for high-latency tunnels, or lower for faster failure detection.", "hasChildren": false @@ -7611,9 +7089,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Browser Snapshot Defaults", "help": "Default snapshot capture configuration used when callers do not provide explicit snapshot options. Tune this for consistent capture behavior across channels and automation paths.", "hasChildren": true @@ -7625,9 +7101,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Browser Snapshot Mode", "help": "Default snapshot extraction mode controlling how page content is transformed for agent consumption. Choose the mode that balances readability, fidelity, and token footprint for your workflows.", "hasChildren": false @@ -7639,9 +7113,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Browser SSRF Policy", "help": "Server-side request forgery guardrail settings for browser/network fetch paths that could reach internal hosts. Keep restrictive defaults in production and open only explicitly approved targets.", "hasChildren": true @@ -7653,9 +7125,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Browser Allowed Hostnames", "help": "Explicit hostname allowlist exceptions for SSRF policy checks on browser/network requests. Keep this list minimal and review entries regularly to avoid stale broad access.", "hasChildren": true @@ -7677,9 +7147,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Browser Allow Private Network", "help": "Legacy alias for browser.ssrfPolicy.dangerouslyAllowPrivateNetwork. Prefer the dangerously-named key so risk intent is explicit.", "hasChildren": false @@ -7691,11 +7159,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "advanced", - "security" - ], + "tags": ["access", "advanced", "security"], "label": "Browser Dangerously Allow Private Network", "help": "Allows access to private-network address ranges from browser tooling. Default is enabled for trusted-network operator setups; disable to enforce strict public-only resolution checks.", "hasChildren": false @@ -7707,9 +7171,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Browser Hostname Allowlist", "help": "Legacy/alternate hostname allowlist field used by SSRF policy consumers for explicit host exceptions. Use stable exact hostnames and avoid wildcard-like broad patterns.", "hasChildren": true @@ -7731,9 +7193,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Canvas Host", "help": "Canvas host settings for serving canvas assets and local live-reload behavior used by canvas-enabled workflows. Keep disabled unless canvas-hosted assets are actively used.", "hasChildren": true @@ -7745,9 +7205,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Canvas Host Enabled", "help": "Enables the canvas host server process and routes for serving canvas files. Keep disabled when canvas workflows are inactive to reduce exposed local services.", "hasChildren": false @@ -7759,9 +7217,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "reliability" - ], + "tags": ["reliability"], "label": "Canvas Host Live Reload", "help": "Enables automatic live-reload behavior for canvas assets during development workflows. Keep disabled in production-like environments where deterministic output is preferred.", "hasChildren": false @@ -7773,9 +7229,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Canvas Host Port", "help": "TCP port used by the canvas host HTTP server when canvas hosting is enabled. Choose a non-conflicting port and align firewall/proxy policy accordingly.", "hasChildren": false @@ -7787,9 +7241,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Canvas Host Root Directory", "help": "Filesystem root directory served by canvas host for canvas content and static assets. Use a dedicated directory and avoid broad repo roots for least-privilege file exposure.", "hasChildren": false @@ -7801,9 +7253,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Channels", "help": "Channel provider configurations plus shared defaults that control access policies, heartbeat visibility, and per-surface behavior. Keep defaults centralized and override per provider only where required.", "hasChildren": true @@ -7815,10 +7265,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "BlueBubbles", "help": "iMessage via the BlueBubbles mac app + REST API.", "hasChildren": true @@ -7856,10 +7303,7 @@ { "path": "channels.bluebubbles.accounts.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -7891,10 +7335,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -7915,12 +7356,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -7949,10 +7385,7 @@ { "path": "channels.bluebubbles.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -7964,11 +7397,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -8099,11 +7528,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -8152,19 +7577,11 @@ { "path": "channels.bluebubbles.accounts.*.password", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "hasChildren": true }, { @@ -8381,10 +7798,7 @@ { "path": "channels.bluebubbles.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -8416,10 +7830,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -8450,19 +7861,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "deprecated": false, "sensitive": false, - "tags": [ - "access", - "channels", - "network" - ], + "tags": ["access", "channels", "network"], "label": "BlueBubbles DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.bluebubbles.allowFrom=[\"*\"].", "hasChildren": false @@ -8490,10 +7892,7 @@ { "path": "channels.bluebubbles.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -8505,11 +7904,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -8640,11 +8035,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -8693,19 +8084,11 @@ { "path": "channels.bluebubbles.password", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "hasChildren": true }, { @@ -8785,10 +8168,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord", "help": "very well supported right now.", "hasChildren": true @@ -8828,14 +8208,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "group-mentions", - "group-all", - "direct", - "all", - "off", - "none" - ], + "enumValues": ["group-mentions", "group-all", "direct", "all", "off", "none"], "deprecated": false, "sensitive": false, "tags": [], @@ -9094,10 +8467,7 @@ { "path": "channels.discord.accounts.*.allowBots", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -9117,10 +8487,7 @@ { "path": "channels.discord.accounts.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -9272,10 +8639,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -9294,10 +8658,7 @@ { "path": "channels.discord.accounts.*.commands.native", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -9307,10 +8668,7 @@ { "path": "channels.discord.accounts.*.commands.nativeSkills", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -9370,10 +8728,7 @@ { "path": "channels.discord.accounts.*.dm.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -9403,10 +8758,7 @@ { "path": "channels.discord.accounts.*.dm.groupChannels.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -9428,12 +8780,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -9454,12 +8801,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -9628,10 +8970,7 @@ { "path": "channels.discord.accounts.*.execApprovals.approvers.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -9683,11 +9022,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "dm", - "channel", - "both" - ], + "enumValues": ["dm", "channel", "both"], "deprecated": false, "sensitive": false, "tags": [], @@ -9698,11 +9033,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -9762,17 +9093,9 @@ { "path": "channels.discord.accounts.*.guilds.*.channels.*.autoArchiveDuration", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, - "enumValues": [ - "60", - "1440", - "4320", - "10080" - ], + "enumValues": ["60", "1440", "4320", "10080"], "deprecated": false, "sensitive": false, "tags": [], @@ -9841,10 +9164,7 @@ { "path": "channels.discord.accounts.*.guilds.*.channels.*.roles.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -10044,10 +9364,7 @@ { "path": "channels.discord.accounts.*.guilds.*.channels.*.users.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -10069,12 +9386,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "own", - "all", - "allowlist" - ], + "enumValues": ["off", "own", "all", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -10103,10 +9415,7 @@ { "path": "channels.discord.accounts.*.guilds.*.roles.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -10286,10 +9595,7 @@ { "path": "channels.discord.accounts.*.guilds.*.users.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -10431,11 +9737,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -10494,19 +9796,11 @@ { "path": "channels.discord.accounts.*.pluralkit.token", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "hasChildren": true }, { @@ -10644,12 +9938,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "online", - "dnd", - "idle", - "invisible" - ], + "enumValues": ["online", "dnd", "idle", "invisible"], "deprecated": false, "sensitive": false, "tags": [], @@ -10658,17 +9947,9 @@ { "path": "channels.discord.accounts.*.streaming", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, - "enumValues": [ - "off", - "partial", - "block", - "progress" - ], + "enumValues": ["off", "partial", "block", "progress"], "deprecated": false, "sensitive": false, "tags": [], @@ -10679,11 +9960,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "partial", - "block", - "off" - ], + "enumValues": ["partial", "block", "off"], "deprecated": false, "sensitive": false, "tags": [], @@ -10762,19 +10039,11 @@ { "path": "channels.discord.accounts.*.token", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "hasChildren": true }, { @@ -10932,12 +10201,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "always", - "inbound", - "tagged" - ], + "enumValues": ["off", "always", "inbound", "tagged"], "deprecated": false, "sensitive": false, "tags": [], @@ -11066,20 +10330,11 @@ { "path": "channels.discord.accounts.*.voice.tts.elevenlabs.apiKey", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "media", - "network", - "security" - ], + "tags": ["auth", "channels", "media", "network", "security"], "hasChildren": true }, { @@ -11117,11 +10372,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "auto", - "on", - "off" - ], + "enumValues": ["auto", "on", "off"], "deprecated": false, "sensitive": false, "tags": [], @@ -11262,10 +10513,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "final", - "all" - ], + "enumValues": ["final", "all"], "deprecated": false, "sensitive": false, "tags": [], @@ -11374,20 +10622,11 @@ { "path": "channels.discord.accounts.*.voice.tts.openai.apiKey", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "media", - "network", - "security" - ], + "tags": ["auth", "channels", "media", "network", "security"], "hasChildren": true }, { @@ -11485,11 +10724,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "elevenlabs", - "openai", - "edge" - ], + "enumValues": ["elevenlabs", "openai", "edge"], "deprecated": false, "sensitive": false, "tags": [], @@ -11530,14 +10765,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "group-mentions", - "group-all", - "direct", - "all", - "off", - "none" - ], + "enumValues": ["group-mentions", "group-all", "direct", "all", "off", "none"], "deprecated": false, "sensitive": false, "tags": [], @@ -11750,10 +10978,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Presence Activity", "help": "Discord presence activity text (defaults to custom status).", "hasChildren": false @@ -11765,10 +10990,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Presence Activity Type", "help": "Discord presence activity type (0=Playing,1=Streaming,2=Listening,3=Watching,4=Custom,5=Competing).", "hasChildren": false @@ -11780,10 +11002,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Presence Activity URL", "help": "Discord presence streaming URL (required for activityType=1).", "hasChildren": false @@ -11811,18 +11030,11 @@ { "path": "channels.discord.allowBots", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "channels", - "network" - ], + "tags": ["access", "channels", "network"], "label": "Discord Allow Bot Messages", "help": "Allow bot-authored messages to trigger Discord replies (default: false). Set \"mentions\" to only accept bot messages that mention the bot.", "hasChildren": false @@ -11840,10 +11052,7 @@ { "path": "channels.discord.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -11867,10 +11076,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Auto Presence Degraded Text", "help": "Optional custom status text while runtime/model availability is degraded or unknown (idle).", "hasChildren": false @@ -11882,10 +11088,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Auto Presence Enabled", "help": "Enable automatic Discord bot presence updates based on runtime/model availability signals. When enabled: healthy=>online, degraded/unknown=>idle, exhausted/unavailable=>dnd.", "hasChildren": false @@ -11897,10 +11100,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Auto Presence Exhausted Text", "help": "Optional custom status text while runtime detects exhausted/unavailable model quota (dnd). Supports {reason} template placeholder.", "hasChildren": false @@ -11912,11 +11112,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "reliability" - ], + "tags": ["channels", "network", "reliability"], "label": "Discord Auto Presence Healthy Text", "help": "Optional custom status text while runtime is healthy (online). If omitted, falls back to static channels.discord.activity when set.", "hasChildren": false @@ -11928,11 +11124,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "performance" - ], + "tags": ["channels", "network", "performance"], "label": "Discord Auto Presence Check Interval (ms)", "help": "How often to evaluate Discord auto-presence state in milliseconds (default: 30000).", "hasChildren": false @@ -11944,11 +11136,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "performance" - ], + "tags": ["channels", "network", "performance"], "label": "Discord Auto Presence Min Update Interval (ms)", "help": "Minimum time between actual Discord presence update calls in milliseconds (default: 15000). Prevents status spam on noisy state changes.", "hasChildren": false @@ -12028,10 +11216,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -12050,17 +11235,11 @@ { "path": "channels.discord.commands.native", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Native Commands", "help": "Override native commands for Discord (bool or \"auto\").", "hasChildren": false @@ -12068,17 +11247,11 @@ { "path": "channels.discord.commands.nativeSkills", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Native Skill Commands", "help": "Override native skill commands for Discord (bool or \"auto\").", "hasChildren": false @@ -12090,10 +11263,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Config Writes", "help": "Allow Discord to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -12151,10 +11321,7 @@ { "path": "channels.discord.dm.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -12184,10 +11351,7 @@ { "path": "channels.discord.dm.groupChannels.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -12209,19 +11373,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "deprecated": false, "sensitive": false, - "tags": [ - "access", - "channels", - "network" - ], + "tags": ["access", "channels", "network"], "label": "Discord DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.discord.allowFrom=[\"*\"] (legacy: channels.discord.dm.allowFrom).", "hasChildren": false @@ -12241,19 +11396,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "deprecated": false, "sensitive": false, - "tags": [ - "access", - "channels", - "network" - ], + "tags": ["access", "channels", "network"], "label": "Discord DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.discord.allowFrom=[\"*\"].", "hasChildren": false @@ -12305,10 +11451,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Draft Chunk Break Preference", "help": "Preferred breakpoints for Discord draft chunks (paragraph | newline | sentence). Default: paragraph.", "hasChildren": false @@ -12320,11 +11463,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "performance" - ], + "tags": ["channels", "network", "performance"], "label": "Discord Draft Chunk Max Chars", "help": "Target max size for a Discord stream preview chunk when channels.discord.streaming=\"block\" (default: 800; clamped to channels.discord.textChunkLimit).", "hasChildren": false @@ -12336,10 +11475,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Draft Chunk Min Chars", "help": "Minimum chars before emitting a Discord stream preview update when channels.discord.streaming=\"block\" (default: 200).", "hasChildren": false @@ -12371,11 +11507,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "performance" - ], + "tags": ["channels", "network", "performance"], "label": "Discord EventQueue Listener Timeout (ms)", "help": "Canonical Discord listener timeout control in ms for gateway normalization/enqueue handlers. Default is 120000 in OpenClaw; set per account via channels.discord.accounts..eventQueue.listenerTimeout.", "hasChildren": false @@ -12387,11 +11519,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "performance" - ], + "tags": ["channels", "network", "performance"], "label": "Discord EventQueue Max Concurrency", "help": "Optional Discord EventQueue concurrency override (max concurrent handler executions). Set per account via channels.discord.accounts..eventQueue.maxConcurrency.", "hasChildren": false @@ -12403,11 +11531,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "performance" - ], + "tags": ["channels", "network", "performance"], "label": "Discord EventQueue Max Queue Size", "help": "Optional Discord EventQueue capacity override (max queued events before backpressure). Set per account via channels.discord.accounts..eventQueue.maxQueueSize.", "hasChildren": false @@ -12455,10 +11579,7 @@ { "path": "channels.discord.execApprovals.approvers.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -12510,11 +11631,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "dm", - "channel", - "both" - ], + "enumValues": ["dm", "channel", "both"], "deprecated": false, "sensitive": false, "tags": [], @@ -12525,11 +11642,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -12589,17 +11702,9 @@ { "path": "channels.discord.guilds.*.channels.*.autoArchiveDuration", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, - "enumValues": [ - "60", - "1440", - "4320", - "10080" - ], + "enumValues": ["60", "1440", "4320", "10080"], "deprecated": false, "sensitive": false, "tags": [], @@ -12668,10 +11773,7 @@ { "path": "channels.discord.guilds.*.channels.*.roles.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -12871,10 +11973,7 @@ { "path": "channels.discord.guilds.*.channels.*.users.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -12896,12 +11995,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "own", - "all", - "allowlist" - ], + "enumValues": ["off", "own", "all", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -12930,10 +12024,7 @@ { "path": "channels.discord.guilds.*.roles.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -13113,10 +12204,7 @@ { "path": "channels.discord.guilds.*.users.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -13210,11 +12298,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "performance" - ], + "tags": ["channels", "network", "performance"], "label": "Discord Inbound Worker Timeout (ms)", "help": "Optional queued Discord inbound worker timeout in ms. This is separate from Carbon listener timeouts; defaults to 1800000 and can be disabled with 0. Set per account via channels.discord.accounts..inboundWorker.runTimeoutMs.", "hasChildren": false @@ -13236,10 +12320,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Guild Members Intent", "help": "Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.", "hasChildren": false @@ -13251,10 +12332,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Presence Intent", "help": "Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.", "hasChildren": false @@ -13274,11 +12352,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -13291,11 +12365,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "performance" - ], + "tags": ["channels", "network", "performance"], "label": "Discord Max Lines Per Message", "help": "Soft max line count per Discord message (default: 17).", "hasChildren": false @@ -13337,10 +12407,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord PluralKit Enabled", "help": "Resolve PluralKit proxied messages and treat system members as distinct senders.", "hasChildren": false @@ -13348,19 +12415,11 @@ { "path": "channels.discord.pluralkit.token", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "label": "Discord PluralKit Token", "help": "Optional PluralKit token for resolving private systems or members.", "hasChildren": true @@ -13402,10 +12461,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Proxy URL", "help": "Proxy URL for Discord gateway + API requests (app-id lookup and allowlist resolution). Set per account via channels.discord.accounts..proxy.", "hasChildren": false @@ -13447,11 +12503,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "reliability" - ], + "tags": ["channels", "network", "reliability"], "label": "Discord Retry Attempts", "help": "Max retry attempts for outbound Discord API calls (default: 3).", "hasChildren": false @@ -13463,11 +12515,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "reliability" - ], + "tags": ["channels", "network", "reliability"], "label": "Discord Retry Jitter", "help": "Jitter factor (0-1) applied to Discord retry delays.", "hasChildren": false @@ -13479,12 +12527,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "performance", - "reliability" - ], + "tags": ["channels", "network", "performance", "reliability"], "label": "Discord Retry Max Delay (ms)", "help": "Maximum retry delay cap in ms for Discord outbound calls.", "hasChildren": false @@ -13496,11 +12539,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "reliability" - ], + "tags": ["channels", "network", "reliability"], "label": "Discord Retry Min Delay (ms)", "help": "Minimum retry delay in ms for Discord outbound calls.", "hasChildren": false @@ -13530,18 +12569,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "online", - "dnd", - "idle", - "invisible" - ], + "enumValues": ["online", "dnd", "idle", "invisible"], "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Presence Status", "help": "Discord presence status (online, dnd, idle, invisible).", "hasChildren": false @@ -13549,23 +12580,12 @@ { "path": "channels.discord.streaming", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, - "enumValues": [ - "off", - "partial", - "block", - "progress" - ], + "enumValues": ["off", "partial", "block", "progress"], "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Streaming Mode", "help": "Unified Discord stream preview mode: \"off\" | \"partial\" | \"block\" | \"progress\". \"progress\" maps to \"partial\" on Discord. Legacy boolean/streamMode keys are auto-mapped.", "hasChildren": false @@ -13575,17 +12595,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "partial", - "block", - "off" - ], + "enumValues": ["partial", "block", "off"], "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Stream Mode (Legacy)", "help": "Legacy Discord preview mode alias (off | partial | block); auto-migrated to channels.discord.streaming.", "hasChildren": false @@ -13617,11 +12630,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "storage" - ], + "tags": ["channels", "network", "storage"], "label": "Discord Thread Binding Enabled", "help": "Enable Discord thread binding features (/focus, bound-thread routing/delivery, and thread-bound subagent sessions). Overrides session.threadBindings.enabled when set.", "hasChildren": false @@ -13633,11 +12642,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "storage" - ], + "tags": ["channels", "network", "storage"], "label": "Discord Thread Binding Idle Timeout (hours)", "help": "Inactivity window in hours for Discord thread-bound sessions (/focus and spawned thread sessions). Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set.", "hasChildren": false @@ -13649,12 +12654,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "performance", - "storage" - ], + "tags": ["channels", "network", "performance", "storage"], "label": "Discord Thread Binding Max Age (hours)", "help": "Optional hard max age in hours for Discord thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.", "hasChildren": false @@ -13666,11 +12666,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "storage" - ], + "tags": ["channels", "network", "storage"], "label": "Discord Thread-Bound ACP Spawn", "help": "Allow /acp spawn to auto-create and bind Discord threads for ACP sessions (default: false; opt-in). Set true to enable thread-bound ACP spawns for this account/channel.", "hasChildren": false @@ -13682,11 +12678,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "storage" - ], + "tags": ["channels", "network", "storage"], "label": "Discord Thread-Bound Subagent Spawn", "help": "Allow subagent spawns with thread=true to auto-create and bind Discord threads (default: false; opt-in). Set true to enable thread-bound subagent spawns for this account/channel.", "hasChildren": false @@ -13694,19 +12686,11 @@ { "path": "channels.discord.token", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "label": "Discord Bot Token", "help": "Discord bot token used for gateway and REST API authentication for this provider account. Keep this secret out of committed config and rotate immediately after any leak.", "hasChildren": true @@ -13768,10 +12752,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Component Accent Color", "help": "Accent color for Discord component containers (hex). Set per account via channels.discord.accounts..ui.components.accentColor.", "hasChildren": false @@ -13793,10 +12774,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Voice Auto-Join", "help": "Voice channels to auto-join on startup (list of guildId/channelId entries).", "hasChildren": true @@ -13838,10 +12816,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Voice DAVE Encryption", "help": "Toggle DAVE end-to-end encryption for Discord voice joins (default: true in @discordjs/voice; Discord may require this).", "hasChildren": false @@ -13853,10 +12828,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Voice Decrypt Failure Tolerance", "help": "Consecutive decrypt failures before DAVE attempts session recovery (passed to @discordjs/voice; default: 24).", "hasChildren": false @@ -13868,10 +12840,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Discord Voice Enabled", "help": "Enable Discord voice channel conversations (default: true). Omit channels.discord.voice to keep voice support disabled for the account.", "hasChildren": false @@ -13883,11 +12852,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "media", - "network" - ], + "tags": ["channels", "media", "network"], "label": "Discord Voice Text-to-Speech", "help": "Optional TTS overrides for Discord voice playback (merged with messages.tts).", "hasChildren": true @@ -13897,12 +12862,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "always", - "inbound", - "tagged" - ], + "enumValues": ["off", "always", "inbound", "tagged"], "deprecated": false, "sensitive": false, "tags": [], @@ -14031,20 +12991,11 @@ { "path": "channels.discord.voice.tts.elevenlabs.apiKey", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "media", - "network", - "security" - ], + "tags": ["auth", "channels", "media", "network", "security"], "hasChildren": true }, { @@ -14082,11 +13033,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "auto", - "on", - "off" - ], + "enumValues": ["auto", "on", "off"], "deprecated": false, "sensitive": false, "tags": [], @@ -14227,10 +13174,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "final", - "all" - ], + "enumValues": ["final", "all"], "deprecated": false, "sensitive": false, "tags": [], @@ -14339,20 +13283,11 @@ { "path": "channels.discord.voice.tts.openai.apiKey", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "media", - "network", - "security" - ], + "tags": ["auth", "channels", "media", "network", "security"], "hasChildren": true }, { @@ -14450,11 +13385,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "elevenlabs", - "openai", - "edge" - ], + "enumValues": ["elevenlabs", "openai", "edge"], "deprecated": false, "sensitive": false, "tags": [], @@ -14487,10 +13418,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Feishu", "help": "飞书/Lark enterprise messaging.", "hasChildren": true @@ -14548,10 +13476,7 @@ { "path": "channels.feishu.accounts.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -14571,10 +13496,7 @@ { "path": "channels.feishu.accounts.*.appSecret", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -14676,10 +13598,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -14700,10 +13619,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "websocket", - "webhook" - ], + "enumValues": ["websocket", "webhook"], "deprecated": false, "sensitive": false, "tags": [], @@ -14724,11 +13640,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "open", - "pairing", - "allowlist" - ], + "enumValues": ["open", "pairing", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -14779,10 +13691,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "feishu", - "lark" - ], + "enumValues": ["feishu", "lark"], "deprecated": false, "sensitive": false, "tags": [], @@ -14801,10 +13710,7 @@ { "path": "channels.feishu.accounts.*.encryptKey", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -14854,10 +13760,7 @@ { "path": "channels.feishu.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -14869,11 +13772,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "open", - "allowlist", - "disabled" - ], + "enumValues": ["open", "allowlist", "disabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -14912,10 +13811,7 @@ { "path": "channels.feishu.accounts.*.groups.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -14937,12 +13833,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "group", - "group_sender", - "group_topic", - "group_topic_sender" - ], + "enumValues": ["group", "group_sender", "group_topic", "group_topic_sender"], "deprecated": false, "sensitive": false, "tags": [], @@ -14953,10 +13844,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "disabled", - "enabled" - ], + "enumValues": ["disabled", "enabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -15057,10 +13945,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "disabled", - "enabled" - ], + "enumValues": ["disabled", "enabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -15079,10 +13964,7 @@ { "path": "channels.feishu.accounts.*.groupSenderAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -15094,12 +13976,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "group", - "group_sender", - "group_topic", - "group_topic_sender" - ], + "enumValues": ["group", "group_sender", "group_topic", "group_topic_sender"], "deprecated": false, "sensitive": false, "tags": [], @@ -15130,10 +14007,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "visible", - "hidden" - ], + "enumValues": ["visible", "hidden"], "deprecated": false, "sensitive": false, "tags": [], @@ -15174,11 +14048,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "native", - "escape", - "strip" - ], + "enumValues": ["native", "escape", "strip"], "deprecated": false, "sensitive": false, "tags": [], @@ -15189,11 +14059,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "native", - "ascii", - "simple" - ], + "enumValues": ["native", "ascii", "simple"], "deprecated": false, "sensitive": false, "tags": [], @@ -15224,11 +14090,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "own", - "all" - ], + "enumValues": ["off", "own", "all"], "deprecated": false, "sensitive": false, "tags": [], @@ -15239,11 +14101,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "auto", - "raw", - "card" - ], + "enumValues": ["auto", "raw", "card"], "deprecated": false, "sensitive": false, "tags": [], @@ -15254,10 +14112,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "disabled", - "enabled" - ], + "enumValues": ["disabled", "enabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -15378,10 +14233,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "disabled", - "enabled" - ], + "enumValues": ["disabled", "enabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -15400,10 +14252,7 @@ { "path": "channels.feishu.accounts.*.verificationToken", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -15503,10 +14352,7 @@ { "path": "channels.feishu.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -15526,10 +14372,7 @@ { "path": "channels.feishu.appSecret", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -15631,10 +14474,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -15655,10 +14495,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "websocket", - "webhook" - ], + "enumValues": ["websocket", "webhook"], "defaultValue": "websocket", "deprecated": false, "sensitive": false, @@ -15690,11 +14527,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "pairing", - "allowlist" - ], + "enumValues": ["open", "pairing", "allowlist"], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -15746,10 +14579,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "feishu", - "lark" - ], + "enumValues": ["feishu", "lark"], "deprecated": false, "sensitive": false, "tags": [], @@ -15818,10 +14648,7 @@ { "path": "channels.feishu.encryptKey", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -15871,10 +14698,7 @@ { "path": "channels.feishu.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -15886,11 +14710,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "allowlist", - "disabled" - ], + "enumValues": ["open", "allowlist", "disabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -15929,10 +14749,7 @@ { "path": "channels.feishu.groups.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -15954,12 +14771,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "group", - "group_sender", - "group_topic", - "group_topic_sender" - ], + "enumValues": ["group", "group_sender", "group_topic", "group_topic_sender"], "deprecated": false, "sensitive": false, "tags": [], @@ -15970,10 +14782,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "disabled", - "enabled" - ], + "enumValues": ["disabled", "enabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -16074,10 +14883,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "disabled", - "enabled" - ], + "enumValues": ["disabled", "enabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -16096,10 +14902,7 @@ { "path": "channels.feishu.groupSenderAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -16111,12 +14914,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "group", - "group_sender", - "group_topic", - "group_topic_sender" - ], + "enumValues": ["group", "group_sender", "group_topic", "group_topic_sender"], "deprecated": false, "sensitive": false, "tags": [], @@ -16147,10 +14945,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "visible", - "hidden" - ], + "enumValues": ["visible", "hidden"], "deprecated": false, "sensitive": false, "tags": [], @@ -16191,11 +14986,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "native", - "escape", - "strip" - ], + "enumValues": ["native", "escape", "strip"], "deprecated": false, "sensitive": false, "tags": [], @@ -16206,11 +14997,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "native", - "ascii", - "simple" - ], + "enumValues": ["native", "ascii", "simple"], "deprecated": false, "sensitive": false, "tags": [], @@ -16231,11 +15018,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "off", - "own", - "all" - ], + "enumValues": ["off", "own", "all"], "defaultValue": "own", "deprecated": false, "sensitive": false, @@ -16247,11 +15030,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "auto", - "raw", - "card" - ], + "enumValues": ["auto", "raw", "card"], "deprecated": false, "sensitive": false, "tags": [], @@ -16262,10 +15041,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "disabled", - "enabled" - ], + "enumValues": ["disabled", "enabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -16388,10 +15164,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "disabled", - "enabled" - ], + "enumValues": ["disabled", "enabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -16411,10 +15184,7 @@ { "path": "channels.feishu.verificationToken", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -16489,10 +15259,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Google Chat", "help": "Google Workspace Chat app with HTTP webhook.", "hasChildren": true @@ -16572,10 +15339,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "app-url", - "project-number" - ], + "enumValues": ["app-url", "project-number"], "deprecated": false, "sensitive": false, "tags": [], @@ -16666,10 +15430,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -16728,10 +15489,7 @@ { "path": "channels.googlechat.accounts.*.dm.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -16753,12 +15511,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -16828,10 +15581,7 @@ { "path": "channels.googlechat.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -16843,11 +15593,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -16927,10 +15673,7 @@ { "path": "channels.googlechat.accounts.*.groups.*.users.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -17020,18 +15763,11 @@ { "path": "channels.googlechat.accounts.*.serviceAccount", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "channels", - "network", - "security" - ], + "tags": ["channels", "network", "security"], "hasChildren": true }, { @@ -17090,11 +15826,7 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "channels", - "network", - "security" - ], + "tags": ["channels", "network", "security"], "hasChildren": true }, { @@ -17132,11 +15864,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "replace", - "status_final", - "append" - ], + "enumValues": ["replace", "status_final", "append"], "defaultValue": "replace", "deprecated": false, "sensitive": false, @@ -17158,11 +15886,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "none", - "message", - "reaction" - ], + "enumValues": ["none", "message", "reaction"], "deprecated": false, "sensitive": false, "tags": [], @@ -17243,10 +15967,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "app-url", - "project-number" - ], + "enumValues": ["app-url", "project-number"], "deprecated": false, "sensitive": false, "tags": [], @@ -17337,10 +16058,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -17409,10 +16127,7 @@ { "path": "channels.googlechat.dm.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -17434,12 +16149,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -17509,10 +16219,7 @@ { "path": "channels.googlechat.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -17524,11 +16231,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -17608,10 +16311,7 @@ { "path": "channels.googlechat.groups.*.users.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -17701,18 +16401,11 @@ { "path": "channels.googlechat.serviceAccount", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "channels", - "network", - "security" - ], + "tags": ["channels", "network", "security"], "hasChildren": true }, { @@ -17771,11 +16464,7 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "channels", - "network", - "security" - ], + "tags": ["channels", "network", "security"], "hasChildren": true }, { @@ -17813,11 +16502,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "replace", - "status_final", - "append" - ], + "enumValues": ["replace", "status_final", "append"], "defaultValue": "replace", "deprecated": false, "sensitive": false, @@ -17839,11 +16524,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "none", - "message", - "reaction" - ], + "enumValues": ["none", "message", "reaction"], "deprecated": false, "sensitive": false, "tags": [], @@ -17876,10 +16557,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "iMessage", "help": "this is still a work in progress.", "hasChildren": true @@ -17917,10 +16595,7 @@ { "path": "channels.imessage.accounts.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -18022,10 +16697,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -18086,12 +16758,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -18151,10 +16818,7 @@ { "path": "channels.imessage.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -18166,11 +16830,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -18452,11 +17112,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -18565,10 +17221,7 @@ { "path": "channels.imessage.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -18670,10 +17323,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -18686,11 +17336,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "storage" - ], + "tags": ["channels", "network", "storage"], "label": "iMessage CLI Path", "help": "Filesystem path to the iMessage bridge CLI binary used for send/receive operations. Set explicitly when the binary is not on PATH in service runtime environments.", "hasChildren": false @@ -18702,10 +17348,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "iMessage Config Writes", "help": "Allow iMessage to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -18755,20 +17398,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "defaultValue": "pairing", "deprecated": false, "sensitive": false, - "tags": [ - "access", - "channels", - "network" - ], + "tags": ["access", "channels", "network"], "label": "iMessage DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.imessage.allowFrom=[\"*\"].", "hasChildren": false @@ -18826,10 +17460,7 @@ { "path": "channels.imessage.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -18841,11 +17472,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -19127,11 +17754,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -19234,10 +17857,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "IRC", "help": "classic IRC networks with DM/channel routing and pairing controls.", "hasChildren": true @@ -19275,10 +17895,7 @@ { "path": "channels.irc.accounts.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -19360,10 +17977,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -19394,12 +18008,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -19459,10 +18068,7 @@ { "path": "channels.irc.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -19474,11 +18080,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -19518,10 +18120,7 @@ { "path": "channels.irc.accounts.*.groups.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -19763,11 +18362,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -19850,12 +18445,7 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "hasChildren": false }, { @@ -19905,12 +18495,7 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "hasChildren": false }, { @@ -19996,10 +18581,7 @@ { "path": "channels.irc.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -20081,10 +18663,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -20125,20 +18704,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "defaultValue": "pairing", "deprecated": false, "sensitive": false, - "tags": [ - "access", - "channels", - "network" - ], + "tags": ["access", "channels", "network"], "label": "IRC DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.irc.allowFrom=[\"*\"].", "hasChildren": false @@ -20196,10 +18766,7 @@ { "path": "channels.irc.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -20211,11 +18778,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -20255,10 +18818,7 @@ { "path": "channels.irc.groups.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -20500,11 +19060,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -20577,10 +19133,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "IRC NickServ Enabled", "help": "Enable NickServ identify/register after connect (defaults to enabled when password is configured).", "hasChildren": false @@ -20592,12 +19145,7 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "label": "IRC NickServ Password", "help": "NickServ password used for IDENTIFY/REGISTER (sensitive).", "hasChildren": false @@ -20609,13 +19157,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "auth", - "channels", - "network", - "security", - "storage" - ], + "tags": ["auth", "channels", "network", "security", "storage"], "label": "IRC NickServ Password File", "help": "Optional file path containing NickServ password.", "hasChildren": false @@ -20627,10 +19169,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "IRC NickServ Register", "help": "If true, send NickServ REGISTER on every connect. Use once for initial registration, then disable.", "hasChildren": false @@ -20642,10 +19181,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "IRC NickServ Register Email", "help": "Email used with NickServ REGISTER (required when register=true).", "hasChildren": false @@ -20657,10 +19193,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "IRC NickServ Service", "help": "NickServ service nick (default: NickServ).", "hasChildren": false @@ -20672,12 +19205,7 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "hasChildren": false }, { @@ -20757,10 +19285,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "LINE", "help": "LINE Messaging API bot for Japan/Taiwan/Thailand markets.", "hasChildren": true @@ -20798,10 +19323,7 @@ { "path": "channels.line.accounts.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -20833,12 +19355,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "allowlist", - "pairing", - "disabled" - ], + "enumValues": ["open", "allowlist", "pairing", "disabled"], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -20868,10 +19385,7 @@ { "path": "channels.line.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -20883,11 +19397,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "allowlist", - "disabled" - ], + "enumValues": ["open", "allowlist", "disabled"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -20927,10 +19437,7 @@ { "path": "channels.line.accounts.*.groups.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -21060,10 +19567,7 @@ { "path": "channels.line.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -21105,12 +19609,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "allowlist", - "pairing", - "disabled" - ], + "enumValues": ["open", "allowlist", "pairing", "disabled"], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -21140,10 +19639,7 @@ { "path": "channels.line.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -21155,11 +19651,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "allowlist", - "disabled" - ], + "enumValues": ["open", "allowlist", "disabled"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -21199,10 +19691,7 @@ { "path": "channels.line.groups.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -21326,10 +19815,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Matrix", "help": "open protocol; configure a homeserver + access token.", "hasChildren": true @@ -21438,11 +19924,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "always", - "allowlist", - "off" - ], + "enumValues": ["always", "allowlist", "off"], "deprecated": false, "sensitive": false, "tags": [], @@ -21461,10 +19943,7 @@ { "path": "channels.matrix.autoJoinAllowlist.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -21476,10 +19955,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -21528,10 +20004,7 @@ { "path": "channels.matrix.dm.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -21553,12 +20026,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -21597,10 +20065,7 @@ { "path": "channels.matrix.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -21612,11 +20077,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -21795,10 +20256,7 @@ { "path": "channels.matrix.groups.*.users.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -21840,11 +20298,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -21873,10 +20327,7 @@ { "path": "channels.matrix.password", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -21918,11 +20369,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "first", - "all" - ], + "enumValues": ["off", "first", "all"], "deprecated": false, "sensitive": false, "tags": [], @@ -22111,10 +20558,7 @@ { "path": "channels.matrix.rooms.*.users.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -22136,11 +20580,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "inbound", - "always" - ], + "enumValues": ["off", "inbound", "always"], "deprecated": false, "sensitive": false, "tags": [], @@ -22163,10 +20603,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Mattermost", "help": "self-hosted Slack-style chat; install the plugin to enable.", "hasChildren": true @@ -22224,10 +20661,7 @@ { "path": "channels.mattermost.accounts.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -22297,10 +20731,7 @@ { "path": "channels.mattermost.accounts.*.botToken", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -22362,11 +20793,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "oncall", - "onmessage", - "onchar" - ], + "enumValues": ["oncall", "onmessage", "onchar"], "deprecated": false, "sensitive": false, "tags": [], @@ -22377,10 +20804,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -22419,10 +20843,7 @@ { "path": "channels.mattermost.accounts.*.commands.native", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -22432,10 +20853,7 @@ { "path": "channels.mattermost.accounts.*.commands.nativeSkills", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -22467,12 +20885,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -22502,10 +20915,7 @@ { "path": "channels.mattermost.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -22517,11 +20927,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -22583,11 +20989,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -22628,11 +21030,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "first", - "all" - ], + "enumValues": ["off", "first", "all"], "deprecated": false, "sensitive": false, "tags": [], @@ -22701,10 +21099,7 @@ { "path": "channels.mattermost.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -22718,10 +21113,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Mattermost Base URL", "help": "Base URL for your Mattermost server (e.g., https://chat.example.com).", "hasChildren": false @@ -22779,19 +21171,11 @@ { "path": "channels.mattermost.botToken", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "label": "Mattermost Bot Token", "help": "Bot token from Mattermost System Console -> Integrations -> Bot Accounts.", "hasChildren": true @@ -22851,17 +21235,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "oncall", - "onmessage", - "onchar" - ], + "enumValues": ["oncall", "onmessage", "onchar"], "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Mattermost Chat Mode", "help": "Reply to channel messages on mention (\"oncall\"), on trigger chars (\">\" or \"!\") (\"onchar\"), or on every message (\"onmessage\").", "hasChildren": false @@ -22871,10 +21248,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -22913,10 +21287,7 @@ { "path": "channels.mattermost.commands.native", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -22926,10 +21297,7 @@ { "path": "channels.mattermost.commands.nativeSkills", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -22943,10 +21311,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Mattermost Config Writes", "help": "Allow Mattermost to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -22976,12 +21341,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -23011,10 +21371,7 @@ { "path": "channels.mattermost.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -23026,11 +21383,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -23092,11 +21445,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -23119,10 +21468,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Mattermost Onchar Prefixes", "help": "Trigger prefixes for onchar mode (default: [\">\", \"!\"]).", "hasChildren": true @@ -23142,11 +21488,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "first", - "all" - ], + "enumValues": ["off", "first", "all"], "deprecated": false, "sensitive": false, "tags": [], @@ -23159,10 +21501,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Mattermost Require Mention", "help": "Require @mention in channels before responding (default: true).", "hasChildren": false @@ -23194,10 +21533,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Microsoft Teams", "help": "Bot Framework; enterprise support.", "hasChildren": true @@ -23235,19 +21571,11 @@ { "path": "channels.msteams.appPassword", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "hasChildren": true }, { @@ -23345,10 +21673,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -23361,10 +21686,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "MS Teams Config Writes", "help": "Allow Microsoft Teams to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -23404,12 +21726,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -23481,11 +21798,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -23577,11 +21890,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -23642,10 +21951,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "thread", - "top-level" - ], + "enumValues": ["thread", "top-level"], "deprecated": false, "sensitive": false, "tags": [], @@ -23726,10 +22032,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "thread", - "top-level" - ], + "enumValues": ["thread", "top-level"], "deprecated": false, "sensitive": false, "tags": [], @@ -23900,10 +22203,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "thread", - "top-level" - ], + "enumValues": ["thread", "top-level"], "deprecated": false, "sensitive": false, "tags": [], @@ -24126,10 +22426,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Nextcloud Talk", "help": "Self-hosted chat via Nextcloud Talk webhook bots.", "hasChildren": true @@ -24177,10 +22474,7 @@ { "path": "channels.nextcloud-talk.accounts.*.apiPassword", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -24300,10 +22594,7 @@ { "path": "channels.nextcloud-talk.accounts.*.botSecret", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -24355,10 +22646,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -24379,12 +22667,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -24456,11 +22739,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -24492,11 +22771,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -24765,10 +23040,7 @@ { "path": "channels.nextcloud-talk.apiPassword", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -24888,10 +23160,7 @@ { "path": "channels.nextcloud-talk.botSecret", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -24943,10 +23212,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -24977,12 +23243,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -25054,11 +23315,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -25090,11 +23347,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -25347,10 +23600,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Nostr", "help": "Decentralized DMs via Nostr relays (NIP-04)", "hasChildren": true @@ -25368,10 +23618,7 @@ { "path": "channels.nostr.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -25393,12 +23640,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -25429,11 +23671,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -25576,10 +23814,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Signal", "help": "signal-cli linked device; more setup (David Reagans: \"Hop on Discord.\").", "hasChildren": true @@ -25591,10 +23826,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Signal Account", "help": "Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state.", "hasChildren": false @@ -25672,10 +23904,7 @@ { "path": "channels.signal.accounts.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -25767,10 +23996,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -25821,12 +24047,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -25886,10 +24107,7 @@ { "path": "channels.signal.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -25901,11 +24119,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -26227,11 +24441,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -26270,10 +24480,7 @@ { "path": "channels.signal.accounts.*.reactionAllowlist.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -26285,12 +24492,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "ack", - "minimal", - "extensive" - ], + "enumValues": ["off", "ack", "minimal", "extensive"], "deprecated": false, "sensitive": false, "tags": [], @@ -26301,12 +24503,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "own", - "all", - "allowlist" - ], + "enumValues": ["off", "own", "all", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -26405,10 +24602,7 @@ { "path": "channels.signal.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -26500,10 +24694,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -26526,10 +24717,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Signal Config Writes", "help": "Allow Signal to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -26569,20 +24757,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "defaultValue": "pairing", "deprecated": false, "sensitive": false, - "tags": [ - "access", - "channels", - "network" - ], + "tags": ["access", "channels", "network"], "label": "Signal DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.signal.allowFrom=[\"*\"].", "hasChildren": false @@ -26640,10 +24819,7 @@ { "path": "channels.signal.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -26655,11 +24831,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -26981,11 +25153,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -27024,10 +25192,7 @@ { "path": "channels.signal.reactionAllowlist.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -27039,12 +25204,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "ack", - "minimal", - "extensive" - ], + "enumValues": ["off", "ack", "minimal", "extensive"], "deprecated": false, "sensitive": false, "tags": [], @@ -27055,12 +25215,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "own", - "all", - "allowlist" - ], + "enumValues": ["off", "own", "all", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -27123,10 +25278,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Slack", "help": "supported (Socket Mode).", "hasChildren": true @@ -27274,10 +25426,7 @@ { "path": "channels.slack.accounts.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -27287,19 +25436,11 @@ { "path": "channels.slack.accounts.*.appToken", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "hasChildren": true }, { @@ -27385,19 +25526,11 @@ { "path": "channels.slack.accounts.*.botToken", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "hasChildren": true }, { @@ -27433,10 +25566,7 @@ { "path": "channels.slack.accounts.*.capabilities", "kind": "channel", - "type": [ - "array", - "object" - ], + "type": ["array", "object"], "required": false, "deprecated": false, "sensitive": false, @@ -27716,10 +25846,7 @@ { "path": "channels.slack.accounts.*.channels.*.users.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -27731,10 +25858,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -27753,10 +25877,7 @@ { "path": "channels.slack.accounts.*.commands.native", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -27766,10 +25887,7 @@ { "path": "channels.slack.accounts.*.commands.nativeSkills", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -27829,10 +25947,7 @@ { "path": "channels.slack.accounts.*.dm.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -27862,10 +25977,7 @@ { "path": "channels.slack.accounts.*.dm.groupChannels.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -27887,12 +25999,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -27923,12 +26030,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -27979,11 +26081,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -28074,11 +26172,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -28099,10 +26193,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "socket", - "http" - ], + "enumValues": ["socket", "http"], "deprecated": false, "sensitive": false, "tags": [], @@ -28141,10 +26232,7 @@ { "path": "channels.slack.accounts.*.reactionAllowlist.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -28156,12 +26244,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "own", - "all", - "allowlist" - ], + "enumValues": ["off", "own", "all", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -28240,19 +26323,11 @@ { "path": "channels.slack.accounts.*.signingSecret", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "hasChildren": true }, { @@ -28338,17 +26413,9 @@ { "path": "channels.slack.accounts.*.streaming", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, - "enumValues": [ - "off", - "partial", - "block", - "progress" - ], + "enumValues": ["off", "partial", "block", "progress"], "deprecated": false, "sensitive": false, "tags": [], @@ -28359,11 +26426,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "replace", - "status_final", - "append" - ], + "enumValues": ["replace", "status_final", "append"], "deprecated": false, "sensitive": false, "tags": [], @@ -28394,10 +26457,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "thread", - "channel" - ], + "enumValues": ["thread", "channel"], "deprecated": false, "sensitive": false, "tags": [], @@ -28436,19 +26496,11 @@ { "path": "channels.slack.accounts.*.userToken", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "hasChildren": true }, { @@ -28609,11 +26661,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "channels", - "network" - ], + "tags": ["access", "channels", "network"], "label": "Slack Allow Bot Messages", "help": "Allow bot-authored messages to trigger Slack replies (default: false).", "hasChildren": false @@ -28631,10 +26679,7 @@ { "path": "channels.slack.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -28644,19 +26689,11 @@ { "path": "channels.slack.appToken", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "label": "Slack App Token", "help": "Slack app-level token used for Socket Mode connections and event transport when enabled. Use least-privilege app scopes and store this token as a secret.", "hasChildren": true @@ -28744,19 +26781,11 @@ { "path": "channels.slack.botToken", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "label": "Slack Bot Token", "help": "Slack bot token used for standard chat actions in the configured workspace. Keep this credential scoped and rotate if workspace app permissions change.", "hasChildren": true @@ -28794,10 +26823,7 @@ { "path": "channels.slack.capabilities", "kind": "channel", - "type": [ - "array", - "object" - ], + "type": ["array", "object"], "required": false, "deprecated": false, "sensitive": false, @@ -28821,10 +26847,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Slack Interactive Replies", "help": "Enable agent-authored Slack interactive reply directives (`[[slack_buttons: ...]]`, `[[slack_select: ...]]`). Default: false.", "hasChildren": false @@ -29082,10 +27105,7 @@ { "path": "channels.slack.channels.*.users.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -29097,10 +27117,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -29119,17 +27136,11 @@ { "path": "channels.slack.commands.native", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Slack Native Commands", "help": "Override native commands for Slack (bool or \"auto\").", "hasChildren": false @@ -29137,17 +27148,11 @@ { "path": "channels.slack.commands.nativeSkills", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Slack Native Skill Commands", "help": "Override native skill commands for Slack (bool or \"auto\").", "hasChildren": false @@ -29159,10 +27164,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Slack Config Writes", "help": "Allow Slack to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -29220,10 +27222,7 @@ { "path": "channels.slack.dm.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -29253,10 +27252,7 @@ { "path": "channels.slack.dm.groupChannels.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -29278,19 +27274,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "deprecated": false, "sensitive": false, - "tags": [ - "access", - "channels", - "network" - ], + "tags": ["access", "channels", "network"], "label": "Slack DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.slack.allowFrom=[\"*\"] (legacy: channels.slack.dm.allowFrom).", "hasChildren": false @@ -29320,19 +27307,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "deprecated": false, "sensitive": false, - "tags": [ - "access", - "channels", - "network" - ], + "tags": ["access", "channels", "network"], "label": "Slack DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.slack.allowFrom=[\"*\"].", "hasChildren": false @@ -29382,11 +27360,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -29478,11 +27452,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -29503,10 +27473,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "socket", - "http" - ], + "enumValues": ["socket", "http"], "defaultValue": "socket", "deprecated": false, "sensitive": false, @@ -29530,10 +27497,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Slack Native Streaming", "help": "Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming is partial (default: true).", "hasChildren": false @@ -29551,10 +27515,7 @@ { "path": "channels.slack.reactionAllowlist.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -29566,12 +27527,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "own", - "all", - "allowlist" - ], + "enumValues": ["off", "own", "all", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -29650,19 +27606,11 @@ { "path": "channels.slack.signingSecret", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "hasChildren": true }, { @@ -29748,23 +27696,12 @@ { "path": "channels.slack.streaming", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, - "enumValues": [ - "off", - "partial", - "block", - "progress" - ], + "enumValues": ["off", "partial", "block", "progress"], "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Slack Streaming Mode", "help": "Unified Slack stream preview mode: \"off\" | \"partial\" | \"block\" | \"progress\". Legacy boolean/streamMode keys are auto-mapped.", "hasChildren": false @@ -29774,17 +27711,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "replace", - "status_final", - "append" - ], + "enumValues": ["replace", "status_final", "append"], "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Slack Stream Mode (Legacy)", "help": "Legacy Slack preview mode alias (replace | status_final | append); auto-migrated to channels.slack.streaming.", "hasChildren": false @@ -29814,16 +27744,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "thread", - "channel" - ], + "enumValues": ["thread", "channel"], "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Slack Thread History Scope", "help": "Scope for Slack thread history context (\"thread\" isolates per thread; \"channel\" reuses channel history).", "hasChildren": false @@ -29835,10 +27759,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Slack Thread Parent Inheritance", "help": "If true, Slack thread sessions inherit the parent channel transcript (default: false).", "hasChildren": false @@ -29850,11 +27771,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "performance" - ], + "tags": ["channels", "network", "performance"], "label": "Slack Thread Initial History Limit", "help": "Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable).", "hasChildren": false @@ -29872,19 +27789,11 @@ { "path": "channels.slack.userToken", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "label": "Slack User Token", "help": "Optional Slack user token for workflows requiring user-context API access beyond bot permissions. Use sparingly and audit scopes because this token can carry broader authority.", "hasChildren": true @@ -29927,12 +27836,7 @@ "defaultValue": true, "deprecated": false, "sensitive": false, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "label": "Slack User Token Read Only", "help": "When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes.", "hasChildren": false @@ -29955,10 +27859,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Synology Chat", "help": "Connect your Synology NAS Chat to OpenClaw", "hasChildren": true @@ -29979,10 +27880,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Telegram", "help": "simplest way to get started — register a bot with @BotFather and get going.", "hasChildren": true @@ -30110,10 +28008,7 @@ { "path": "channels.telegram.accounts.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -30173,19 +28068,11 @@ { "path": "channels.telegram.accounts.*.botToken", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "hasChildren": true }, { @@ -30221,10 +28108,7 @@ { "path": "channels.telegram.accounts.*.capabilities", "kind": "channel", - "type": [ - "array", - "object" - ], + "type": ["array", "object"], "required": false, "deprecated": false, "sensitive": false, @@ -30246,13 +28130,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "dm", - "group", - "all", - "allowlist" - ], + "enumValues": ["off", "dm", "group", "all", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -30263,10 +28141,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -30285,10 +28160,7 @@ { "path": "channels.telegram.accounts.*.commands.native", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -30298,10 +28170,7 @@ { "path": "channels.telegram.accounts.*.commands.nativeSkills", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -30361,10 +28230,7 @@ { "path": "channels.telegram.accounts.*.defaultTo", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -30404,10 +28270,7 @@ { "path": "channels.telegram.accounts.*.direct.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -30419,12 +28282,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -30673,10 +28531,7 @@ { "path": "channels.telegram.accounts.*.direct.*.topics.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -30708,11 +28563,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -30773,12 +28624,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -30908,10 +28754,7 @@ { "path": "channels.telegram.accounts.*.execApprovals.approvers.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -30953,11 +28796,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "dm", - "channel", - "both" - ], + "enumValues": ["dm", "channel", "both"], "deprecated": false, "sensitive": false, "tags": [], @@ -30976,10 +28815,7 @@ { "path": "channels.telegram.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -30991,11 +28827,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -31035,10 +28867,7 @@ { "path": "channels.telegram.accounts.*.groups.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -31070,11 +28899,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -31313,10 +29138,7 @@ { "path": "channels.telegram.accounts.*.groups.*.topics.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -31348,11 +29170,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -31493,11 +29311,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -31548,10 +29362,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "ipv4first", - "verbatim" - ], + "enumValues": ["ipv4first", "verbatim"], "deprecated": false, "sensitive": false, "tags": [], @@ -31572,12 +29383,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "ack", - "minimal", - "extensive" - ], + "enumValues": ["off", "ack", "minimal", "extensive"], "deprecated": false, "sensitive": false, "tags": [], @@ -31588,11 +29394,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "own", - "all" - ], + "enumValues": ["off", "own", "all"], "deprecated": false, "sensitive": false, "tags": [], @@ -31671,17 +29473,9 @@ { "path": "channels.telegram.accounts.*.streaming", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, - "enumValues": [ - "off", - "partial", - "block", - "progress" - ], + "enumValues": ["off", "partial", "block", "progress"], "deprecated": false, "sensitive": false, "tags": [], @@ -31692,11 +29486,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "partial", - "block" - ], + "enumValues": ["off", "partial", "block"], "deprecated": false, "sensitive": false, "tags": [], @@ -31835,19 +29625,11 @@ { "path": "channels.telegram.accounts.*.webhookSecret", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "hasChildren": true }, { @@ -31993,10 +29775,7 @@ { "path": "channels.telegram.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -32056,19 +29835,11 @@ { "path": "channels.telegram.botToken", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "label": "Telegram Bot Token", "help": "Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected.", "hasChildren": true @@ -32106,10 +29877,7 @@ { "path": "channels.telegram.capabilities", "kind": "channel", - "type": [ - "array", - "object" - ], + "type": ["array", "object"], "required": false, "deprecated": false, "sensitive": false, @@ -32131,19 +29899,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "dm", - "group", - "all", - "allowlist" - ], + "enumValues": ["off", "dm", "group", "all", "allowlist"], "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Telegram Inline Buttons", "help": "Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior.", "hasChildren": false @@ -32153,10 +29912,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -32175,17 +29931,11 @@ { "path": "channels.telegram.commands.native", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Telegram Native Commands", "help": "Override native commands for Telegram (bool or \"auto\").", "hasChildren": false @@ -32193,17 +29943,11 @@ { "path": "channels.telegram.commands.nativeSkills", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Telegram Native Skill Commands", "help": "Override native skill commands for Telegram (bool or \"auto\").", "hasChildren": false @@ -32215,10 +29959,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Telegram Config Writes", "help": "Allow Telegram to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -32230,10 +29971,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Telegram Custom Commands", "help": "Additional Telegram bot menu commands (merged with native; conflicts ignored).", "hasChildren": true @@ -32281,10 +30019,7 @@ { "path": "channels.telegram.defaultTo", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -32324,10 +30059,7 @@ { "path": "channels.telegram.direct.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -32339,12 +30071,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -32593,10 +30320,7 @@ { "path": "channels.telegram.direct.*.topics.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -32628,11 +30352,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -32693,20 +30413,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "defaultValue": "pairing", "deprecated": false, "sensitive": false, - "tags": [ - "access", - "channels", - "network" - ], + "tags": ["access", "channels", "network"], "label": "Telegram DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.telegram.allowFrom=[\"*\"].", "hasChildren": false @@ -32798,10 +30509,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Telegram Exec Approvals", "help": "Telegram-native exec approval routing and approver authorization. Enable this only when Telegram should act as an explicit exec-approval client for the selected bot account.", "hasChildren": true @@ -32813,10 +30521,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Telegram Exec Approval Agent Filter", "help": "Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `[\"main\", \"ops-agent\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram.", "hasChildren": true @@ -32838,10 +30543,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Telegram Exec Approval Approvers", "help": "Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs; prompts are only delivered to these approvers when target includes dm.", "hasChildren": true @@ -32849,10 +30551,7 @@ { "path": "channels.telegram.execApprovals.approvers.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -32866,10 +30565,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Telegram Exec Approvals Enabled", "help": "Enable Telegram exec approvals for this account. When false or unset, Telegram messages/buttons cannot approve exec requests.", "hasChildren": false @@ -32881,11 +30577,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "storage" - ], + "tags": ["channels", "network", "storage"], "label": "Telegram Exec Approval Session Filter", "help": "Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions.", "hasChildren": true @@ -32905,17 +30597,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "dm", - "channel", - "both" - ], + "enumValues": ["dm", "channel", "both"], "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Telegram Exec Approval Target", "help": "Controls where Telegram approval prompts are sent: \"dm\" sends to approver DMs (default), \"channel\" sends to the originating Telegram chat/topic, and \"both\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics.", "hasChildren": false @@ -32933,10 +30618,7 @@ { "path": "channels.telegram.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -32948,11 +30630,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -32992,10 +30670,7 @@ { "path": "channels.telegram.groups.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -33027,11 +30702,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -33270,10 +30941,7 @@ { "path": "channels.telegram.groups.*.topics.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -33305,11 +30973,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -33450,11 +31114,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -33497,10 +31157,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Telegram autoSelectFamily", "help": "Override Node autoSelectFamily for Telegram (true=enable, false=disable).", "hasChildren": false @@ -33510,10 +31167,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "ipv4first", - "verbatim" - ], + "enumValues": ["ipv4first", "verbatim"], "deprecated": false, "sensitive": false, "tags": [], @@ -33534,12 +31188,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "ack", - "minimal", - "extensive" - ], + "enumValues": ["off", "ack", "minimal", "extensive"], "deprecated": false, "sensitive": false, "tags": [], @@ -33550,11 +31199,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "own", - "all" - ], + "enumValues": ["off", "own", "all"], "deprecated": false, "sensitive": false, "tags": [], @@ -33597,11 +31242,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "reliability" - ], + "tags": ["channels", "network", "reliability"], "label": "Telegram Retry Attempts", "help": "Max retry attempts for outbound Telegram API calls (default: 3).", "hasChildren": false @@ -33613,11 +31254,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "reliability" - ], + "tags": ["channels", "network", "reliability"], "label": "Telegram Retry Jitter", "help": "Jitter factor (0-1) applied to Telegram retry delays.", "hasChildren": false @@ -33629,12 +31266,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "performance", - "reliability" - ], + "tags": ["channels", "network", "performance", "reliability"], "label": "Telegram Retry Max Delay (ms)", "help": "Maximum retry delay cap in ms for Telegram outbound calls.", "hasChildren": false @@ -33646,11 +31278,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "reliability" - ], + "tags": ["channels", "network", "reliability"], "label": "Telegram Retry Min Delay (ms)", "help": "Minimum retry delay in ms for Telegram outbound calls.", "hasChildren": false @@ -33658,23 +31286,12 @@ { "path": "channels.telegram.streaming", "kind": "channel", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, - "enumValues": [ - "off", - "partial", - "block", - "progress" - ], + "enumValues": ["off", "partial", "block", "progress"], "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Telegram Streaming Mode", "help": "Unified Telegram stream preview mode: \"off\" | \"partial\" | \"block\" | \"progress\" (default: \"partial\"). \"progress\" maps to \"partial\" on Telegram. Legacy boolean/streamMode keys are auto-mapped.", "hasChildren": false @@ -33684,11 +31301,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "partial", - "block" - ], + "enumValues": ["off", "partial", "block"], "deprecated": false, "sensitive": false, "tags": [], @@ -33721,11 +31334,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "storage" - ], + "tags": ["channels", "network", "storage"], "label": "Telegram Thread Binding Enabled", "help": "Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set.", "hasChildren": false @@ -33737,11 +31346,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "storage" - ], + "tags": ["channels", "network", "storage"], "label": "Telegram Thread Binding Idle Timeout (hours)", "help": "Inactivity window in hours for Telegram bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set.", "hasChildren": false @@ -33753,12 +31358,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "performance", - "storage" - ], + "tags": ["channels", "network", "performance", "storage"], "label": "Telegram Thread Binding Max Age (hours)", "help": "Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.", "hasChildren": false @@ -33770,11 +31370,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "storage" - ], + "tags": ["channels", "network", "storage"], "label": "Telegram Thread-Bound ACP Spawn", "help": "Allow ACP spawns with thread=true to auto-bind Telegram current conversations when supported.", "hasChildren": false @@ -33786,11 +31382,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "storage" - ], + "tags": ["channels", "network", "storage"], "label": "Telegram Thread-Bound Subagent Spawn", "help": "Allow subagent spawns with thread=true to auto-bind Telegram current conversations when supported.", "hasChildren": false @@ -33802,11 +31394,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "performance" - ], + "tags": ["channels", "network", "performance"], "label": "Telegram API Timeout (seconds)", "help": "Max seconds before Telegram API requests are aborted (default: 500 per grammY).", "hasChildren": false @@ -33864,19 +31452,11 @@ { "path": "channels.telegram.webhookSecret", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "channels", - "network", - "security" - ], + "tags": ["auth", "channels", "network", "security"], "hasChildren": true }, { @@ -33926,10 +31506,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Tlon", "help": "Decentralized messaging on Urbit", "hasChildren": true @@ -34179,10 +31756,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "restricted", - "open" - ], + "enumValues": ["restricted", "open"], "deprecated": false, "sensitive": false, "tags": [], @@ -34365,10 +31939,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Twitch", "help": "Twitch chat integration", "hasChildren": true @@ -34428,13 +31999,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "moderator", - "owner", - "vip", - "subscriber", - "all" - ], + "enumValues": ["moderator", "owner", "vip", "subscriber", "all"], "deprecated": false, "sensitive": false, "tags": [], @@ -34503,10 +32068,7 @@ { "path": "channels.twitch.accounts.*.expiresIn", "kind": "channel", - "type": [ - "null", - "number" - ], + "type": ["null", "number"], "required": false, "deprecated": false, "sensitive": false, @@ -34578,13 +32140,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "moderator", - "owner", - "vip", - "subscriber", - "all" - ], + "enumValues": ["moderator", "owner", "vip", "subscriber", "all"], "deprecated": false, "sensitive": false, "tags": [], @@ -34653,10 +32209,7 @@ { "path": "channels.twitch.expiresIn", "kind": "channel", - "type": [ - "null", - "number" - ], + "type": ["null", "number"], "required": false, "deprecated": false, "sensitive": false, @@ -34678,11 +32231,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "bullets", - "code", - "off" - ], + "enumValues": ["bullets", "code", "off"], "deprecated": false, "sensitive": false, "tags": [], @@ -34755,10 +32304,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "WhatsApp", "help": "works with your own number; recommend a separate phone + eSIM.", "hasChildren": true @@ -34819,11 +32365,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "always", - "mentions", - "never" - ], + "enumValues": ["always", "mentions", "never"], "defaultValue": "mentions", "deprecated": false, "sensitive": false, @@ -34935,10 +32477,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -34990,12 +32529,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -35067,11 +32601,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -35343,11 +32873,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -35459,11 +32985,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "always", - "mentions", - "never" - ], + "enumValues": ["always", "mentions", "never"], "defaultValue": "mentions", "deprecated": false, "sensitive": false, @@ -35605,10 +33127,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "length", - "newline" - ], + "enumValues": ["length", "newline"], "deprecated": false, "sensitive": false, "tags": [], @@ -35621,10 +33140,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "WhatsApp Config Writes", "help": "Allow WhatsApp to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -35637,11 +33153,7 @@ "defaultValue": 0, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network", - "performance" - ], + "tags": ["channels", "network", "performance"], "label": "WhatsApp Message Debounce (ms)", "help": "Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable).", "hasChildren": false @@ -35681,20 +33193,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "defaultValue": "pairing", "deprecated": false, "sensitive": false, - "tags": [ - "access", - "channels", - "network" - ], + "tags": ["access", "channels", "network"], "label": "WhatsApp DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.whatsapp.allowFrom=[\"*\"].", "hasChildren": false @@ -35764,11 +33267,7 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -36040,11 +33539,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -36088,10 +33583,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "WhatsApp Self-Phone Mode", "help": "Same-phone setup (bot uses your personal WhatsApp number).", "hasChildren": false @@ -36123,10 +33615,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Zalo", "help": "Vietnam-focused messaging platform with Bot API.", "hasChildren": true @@ -36164,10 +33653,7 @@ { "path": "channels.zalo.accounts.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -36177,10 +33663,7 @@ { "path": "channels.zalo.accounts.*.botToken", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -36222,12 +33705,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -36256,10 +33734,7 @@ { "path": "channels.zalo.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -36271,11 +33746,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -36296,11 +33767,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -36369,10 +33836,7 @@ { "path": "channels.zalo.accounts.*.webhookSecret", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -36432,10 +33896,7 @@ { "path": "channels.zalo.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -36445,10 +33906,7 @@ { "path": "channels.zalo.botToken", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -36500,12 +33958,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -36534,10 +33987,7 @@ { "path": "channels.zalo.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -36549,11 +33999,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -36574,11 +34020,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -36647,10 +34089,7 @@ { "path": "channels.zalo.webhookSecret", "kind": "channel", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -36704,10 +34143,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "channels", - "network" - ], + "tags": ["channels", "network"], "label": "Zalo Personal", "help": "Zalo personal account via QR code login.", "hasChildren": true @@ -36745,10 +34181,7 @@ { "path": "channels.zalouser.accounts.*.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -36770,12 +34203,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -36804,10 +34232,7 @@ { "path": "channels.zalouser.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -36819,11 +34244,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -36974,11 +34395,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -37037,10 +34454,7 @@ { "path": "channels.zalouser.allowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -37072,12 +34486,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "pairing", - "allowlist", - "open", - "disabled" - ], + "enumValues": ["pairing", "allowlist", "open", "disabled"], "deprecated": false, "sensitive": false, "tags": [], @@ -37106,10 +34515,7 @@ { "path": "channels.zalouser.groupAllowFrom.*", "kind": "channel", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -37121,11 +34527,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "open", - "disabled", - "allowlist" - ], + "enumValues": ["open", "disabled", "allowlist"], "deprecated": false, "sensitive": false, "tags": [], @@ -37276,11 +34678,7 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": [ - "off", - "bullets", - "code" - ], + "enumValues": ["off", "bullets", "code"], "deprecated": false, "sensitive": false, "tags": [], @@ -37333,9 +34731,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "CLI", "help": "CLI presentation controls for local command output behavior such as banner and tagline style. Use this section to keep startup output aligned with operator preference without changing runtime behavior.", "hasChildren": true @@ -37347,9 +34743,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "CLI Banner", "help": "CLI startup banner controls for title/version line and tagline style behavior. Keep banner enabled for fast version/context checks, then tune tagline mode to your preferred noise level.", "hasChildren": true @@ -37361,9 +34755,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "CLI Banner Tagline Mode", "help": "Controls tagline style in the CLI startup banner: \"random\" (default) picks from the rotating tagline pool, \"default\" always shows the neutral default tagline, and \"off\" hides tagline text while keeping the banner version line.", "hasChildren": false @@ -37381,9 +34773,7 @@ }, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Commands", "help": "Controls chat command surfaces, owner gating, and elevated command access behavior across providers. Keep defaults unless you need stricter operator controls or broader command availability.", "hasChildren": true @@ -37395,9 +34785,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Command Elevated Access Rules", "help": "Defines elevated command allow rules by channel and sender for owner-level command surfaces. Use narrow provider-specific identities so privileged commands are not exposed to broad chat audiences.", "hasChildren": true @@ -37415,10 +34803,7 @@ { "path": "commands.allowFrom.*.*", "kind": "core", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -37432,9 +34817,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Allow Bash Chat Command", "help": "Allow bash chat command (`!`; `/bash` alias) to run host shell commands (default: false; requires tools.elevated).", "hasChildren": false @@ -37446,9 +34829,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Bash Foreground Window (ms)", "help": "How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately).", "hasChildren": false @@ -37460,9 +34841,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Allow /config", "help": "Allow /config chat command to read/write config on disk (default: false).", "hasChildren": false @@ -37474,9 +34853,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Allow /debug", "help": "Allow /debug chat command for runtime-only overrides (default: false).", "hasChildren": false @@ -37484,16 +34861,11 @@ { "path": "commands.native", "kind": "core", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": true, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Native Commands", "help": "Registers native slash/menu commands with channels that support command registration (Discord, Slack, Telegram). Keep enabled for discoverability unless you intentionally run text-only command workflows.", "hasChildren": false @@ -37501,16 +34873,11 @@ { "path": "commands.nativeSkills", "kind": "core", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": true, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Native Skill Commands", "help": "Registers native skill commands so users can invoke skills directly from provider command menus where supported. Keep aligned with your skill policy so exposed commands match what operators expect.", "hasChildren": false @@ -37522,9 +34889,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Command Owners", "help": "Explicit owner allowlist for owner-only tools/commands. Use channel-native IDs (optionally prefixed like \"whatsapp:+15551234567\"). '*' is ignored.", "hasChildren": true @@ -37532,10 +34897,7 @@ { "path": "commands.ownerAllowFrom.*", "kind": "core", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -37547,16 +34909,11 @@ "kind": "core", "type": "string", "required": true, - "enumValues": [ - "raw", - "hash" - ], + "enumValues": ["raw", "hash"], "defaultValue": "raw", "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Owner ID Display", "help": "Controls how owner IDs are rendered in the system prompt. Allowed values: raw, hash. Default: raw.", "hasChildren": false @@ -37568,11 +34925,7 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "access", - "auth", - "security" - ], + "tags": ["access", "auth", "security"], "label": "Owner ID Hash Secret", "help": "Optional secret used to HMAC hash owner IDs when ownerDisplay=hash. Prefer env substitution.", "hasChildren": false @@ -37585,9 +34938,7 @@ "defaultValue": true, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Allow Restart", "help": "Allow /restart and gateway restart tool actions (default: true).", "hasChildren": false @@ -37599,9 +34950,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Text Commands", "help": "Enables text-command parsing in chat input in addition to native command surfaces where available. Keep this enabled for compatibility across channels that do not support native command registration.", "hasChildren": false @@ -37613,9 +34962,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Use Access Groups", "help": "Enforce access-group allowlists/policies for commands.", "hasChildren": false @@ -37627,9 +34974,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation" - ], + "tags": ["automation"], "label": "Cron", "help": "Global scheduler settings for stored cron jobs, run concurrency, delivery fallback, and run-session retention. Keep defaults unless you are scaling job volume or integrating external webhook receivers.", "hasChildren": true @@ -37641,9 +34986,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation" - ], + "tags": ["automation"], "label": "Cron Enabled", "help": "Enables cron job execution for stored schedules managed by the gateway. Keep enabled for normal reminder/automation flows, and disable only to pause all cron execution without deleting jobs.", "hasChildren": false @@ -37703,10 +35046,7 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "announce", - "webhook" - ], + "enumValues": ["announce", "webhook"], "deprecated": false, "sensitive": false, "tags": [], @@ -37747,10 +35087,7 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "announce", - "webhook" - ], + "enumValues": ["announce", "webhook"], "deprecated": false, "sensitive": false, "tags": [], @@ -37773,10 +35110,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation", - "performance" - ], + "tags": ["automation", "performance"], "label": "Cron Max Concurrent Runs", "help": "Limits how many cron jobs can execute at the same time when multiple schedules fire together. Use lower values to protect CPU/memory under heavy automation load, or raise carefully for higher throughput.", "hasChildren": false @@ -37788,10 +35122,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation", - "reliability" - ], + "tags": ["automation", "reliability"], "label": "Cron Retry Policy", "help": "Overrides the default retry policy for one-shot jobs when they fail with transient errors (rate limit, overloaded, network, server_error). Omit to use defaults: maxAttempts 3, backoffMs [30000, 60000, 300000], retry all transient types.", "hasChildren": true @@ -37803,10 +35134,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation", - "reliability" - ], + "tags": ["automation", "reliability"], "label": "Cron Retry Backoff (ms)", "help": "Backoff delays in ms for each retry attempt (default: [30000, 60000, 300000]). Use shorter values for faster retries.", "hasChildren": true @@ -37828,11 +35156,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation", - "performance", - "reliability" - ], + "tags": ["automation", "performance", "reliability"], "label": "Cron Retry Max Attempts", "help": "Max retries for one-shot jobs on transient errors before permanent disable (default: 3).", "hasChildren": false @@ -37844,10 +35168,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation", - "reliability" - ], + "tags": ["automation", "reliability"], "label": "Cron Retry Error Types", "help": "Error types to retry: rate_limit, overloaded, network, timeout, server_error. Use to restrict which errors trigger retries; omit to retry all transient types.", "hasChildren": true @@ -37857,13 +35178,7 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "rate_limit", - "overloaded", - "network", - "timeout", - "server_error" - ], + "enumValues": ["rate_limit", "overloaded", "network", "timeout", "server_error"], "deprecated": false, "sensitive": false, "tags": [], @@ -37876,9 +35191,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation" - ], + "tags": ["automation"], "label": "Cron Run Log Pruning", "help": "Pruning controls for per-job cron run history files under `cron/runs/.jsonl`, including size and line retention.", "hasChildren": true @@ -37890,9 +35203,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation" - ], + "tags": ["automation"], "label": "Cron Run Log Keep Lines", "help": "How many trailing run-log lines to retain when a file exceeds maxBytes (default `2000`). Increase for longer forensic history or lower for smaller disks.", "hasChildren": false @@ -37900,17 +35211,11 @@ { "path": "cron.runLog.maxBytes", "kind": "core", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation", - "performance" - ], + "tags": ["automation", "performance"], "label": "Cron Run Log Max Bytes", "help": "Maximum bytes per cron run-log file before pruning rewrites to the last keepLines entries (for example `2mb`, default `2000000`).", "hasChildren": false @@ -37918,17 +35223,11 @@ { "path": "cron.sessionRetention", "kind": "core", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation", - "storage" - ], + "tags": ["automation", "storage"], "label": "Cron Session Retention", "help": "Controls how long completed cron run sessions are kept before pruning (`24h`, `7d`, `1h30m`, or `false` to disable pruning; default: `24h`). Use shorter retention to reduce storage growth on high-frequency schedules.", "hasChildren": false @@ -37940,10 +35239,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation", - "storage" - ], + "tags": ["automation", "storage"], "label": "Cron Store Path", "help": "Path to the cron job store file used to persist scheduled jobs across restarts. Set an explicit path only when you need custom storage layout, backups, or mounted volumes.", "hasChildren": false @@ -37955,9 +35251,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation" - ], + "tags": ["automation"], "label": "Cron Legacy Webhook (Deprecated)", "help": "Deprecated legacy fallback webhook URL used only for old jobs with `notify=true`. Migrate to per-job delivery using `delivery.mode=\"webhook\"` plus `delivery.to`, and avoid relying on this global field.", "hasChildren": false @@ -37965,18 +35259,11 @@ { "path": "cron.webhookToken", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "automation", - "security" - ], + "tags": ["auth", "automation", "security"], "label": "Cron Webhook Bearer Token", "help": "Bearer token attached to cron webhook POST deliveries when webhook mode is used. Prefer secret/env substitution and rotate this token regularly if shared webhook endpoints are internet-reachable.", "hasChildren": true @@ -38018,9 +35305,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability" - ], + "tags": ["observability"], "label": "Diagnostics", "help": "Diagnostics controls for targeted tracing, telemetry export, and cache inspection during debugging. Keep baseline diagnostics minimal in production and enable deeper signals only when investigating issues.", "hasChildren": true @@ -38032,10 +35317,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability", - "storage" - ], + "tags": ["observability", "storage"], "label": "Cache Trace", "help": "Cache-trace logging settings for observing cache decisions and payload context in embedded runs. Enable this temporarily for debugging and disable afterward to reduce sensitive log footprint.", "hasChildren": true @@ -38047,10 +35329,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability", - "storage" - ], + "tags": ["observability", "storage"], "label": "Cache Trace Enabled", "help": "Log cache trace snapshots for embedded agent runs (default: false).", "hasChildren": false @@ -38062,10 +35341,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability", - "storage" - ], + "tags": ["observability", "storage"], "label": "Cache Trace File Path", "help": "JSONL output path for cache trace logs (default: $OPENCLAW_STATE_DIR/logs/cache-trace.jsonl).", "hasChildren": false @@ -38077,10 +35353,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability", - "storage" - ], + "tags": ["observability", "storage"], "label": "Cache Trace Include Messages", "help": "Include full message payloads in trace output (default: true).", "hasChildren": false @@ -38092,10 +35365,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability", - "storage" - ], + "tags": ["observability", "storage"], "label": "Cache Trace Include Prompt", "help": "Include prompt text in trace output (default: true).", "hasChildren": false @@ -38107,10 +35377,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability", - "storage" - ], + "tags": ["observability", "storage"], "label": "Cache Trace Include System", "help": "Include system prompt in trace output (default: true).", "hasChildren": false @@ -38122,9 +35389,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability" - ], + "tags": ["observability"], "label": "Diagnostics Enabled", "help": "Master toggle for diagnostics instrumentation output in logs and telemetry wiring paths. Keep enabled for normal observability, and disable only in tightly constrained environments.", "hasChildren": false @@ -38136,9 +35401,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability" - ], + "tags": ["observability"], "label": "Diagnostics Flags", "help": "Enable targeted diagnostics logs by flag (e.g. [\"telegram.http\"]). Supports wildcards like \"telegram.*\" or \"*\".", "hasChildren": true @@ -38160,9 +35423,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability" - ], + "tags": ["observability"], "label": "OpenTelemetry", "help": "OpenTelemetry export settings for traces, metrics, and logs emitted by gateway components. Use this when integrating with centralized observability backends and distributed tracing pipelines.", "hasChildren": true @@ -38174,9 +35435,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability" - ], + "tags": ["observability"], "label": "OpenTelemetry Enabled", "help": "Enables OpenTelemetry export pipeline for traces, metrics, and logs based on configured endpoint/protocol settings. Keep disabled unless your collector endpoint and auth are fully configured.", "hasChildren": false @@ -38188,9 +35447,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability" - ], + "tags": ["observability"], "label": "OpenTelemetry Endpoint", "help": "Collector endpoint URL used for OpenTelemetry export transport, including scheme and port. Use a reachable, trusted collector endpoint and monitor ingestion errors after rollout.", "hasChildren": false @@ -38202,10 +35459,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability", - "performance" - ], + "tags": ["observability", "performance"], "label": "OpenTelemetry Flush Interval (ms)", "help": "Interval in milliseconds for periodic telemetry flush from buffers to the collector. Increase to reduce export chatter, or lower for faster visibility during active incident response.", "hasChildren": false @@ -38217,9 +35471,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability" - ], + "tags": ["observability"], "label": "OpenTelemetry Headers", "help": "Additional HTTP/gRPC metadata headers sent with OpenTelemetry export requests, often used for tenant auth or routing. Keep secrets in env-backed values and avoid unnecessary header sprawl.", "hasChildren": true @@ -38241,9 +35493,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability" - ], + "tags": ["observability"], "label": "OpenTelemetry Logs Enabled", "help": "Enable log signal export through OpenTelemetry in addition to local logging sinks. Use this when centralized log correlation is required across services and agents.", "hasChildren": false @@ -38255,9 +35505,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability" - ], + "tags": ["observability"], "label": "OpenTelemetry Metrics Enabled", "help": "Enable metrics signal export to the configured OpenTelemetry collector endpoint. Keep enabled for runtime health dashboards, and disable only if metric volume must be minimized.", "hasChildren": false @@ -38269,9 +35517,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability" - ], + "tags": ["observability"], "label": "OpenTelemetry Protocol", "help": "OTel transport protocol for telemetry export: \"http/protobuf\" or \"grpc\" depending on collector support. Use the protocol your observability backend expects to avoid dropped telemetry payloads.", "hasChildren": false @@ -38283,9 +35529,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability" - ], + "tags": ["observability"], "label": "OpenTelemetry Trace Sample Rate", "help": "Trace sampling rate (0-1) controlling how much trace traffic is exported to observability backends. Lower rates reduce overhead/cost, while higher rates improve debugging fidelity.", "hasChildren": false @@ -38297,9 +35541,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability" - ], + "tags": ["observability"], "label": "OpenTelemetry Service Name", "help": "Service name reported in telemetry resource attributes to identify this gateway instance in observability backends. Use stable names so dashboards and alerts remain consistent over deployments.", "hasChildren": false @@ -38311,9 +35553,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability" - ], + "tags": ["observability"], "label": "OpenTelemetry Traces Enabled", "help": "Enable trace signal export to the configured OpenTelemetry collector endpoint. Keep enabled when latency/debug tracing is needed, and disable if you only want metrics/logs.", "hasChildren": false @@ -38325,10 +35565,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability", - "storage" - ], + "tags": ["observability", "storage"], "label": "Stuck Session Warning Threshold (ms)", "help": "Age threshold in milliseconds for emitting stuck-session warnings while a session remains in processing state. Increase for long multi-tool turns to reduce false positives; decrease for faster hang detection.", "hasChildren": false @@ -38340,9 +35577,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Discovery", "help": "Service discovery settings for local mDNS advertisement and optional wide-area presence signaling. Keep discovery scoped to expected networks to avoid leaking service metadata.", "hasChildren": true @@ -38354,9 +35589,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "mDNS Discovery", "help": "mDNS discovery configuration group for local network advertisement and discovery behavior tuning. Keep minimal mode for routine LAN discovery unless extra metadata is required.", "hasChildren": true @@ -38366,16 +35599,10 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "off", - "minimal", - "full" - ], + "enumValues": ["off", "minimal", "full"], "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "mDNS Discovery Mode", "help": "mDNS broadcast mode (\"minimal\" default, \"full\" includes cliPath/sshPort, \"off\" disables mDNS).", "hasChildren": false @@ -38387,9 +35614,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Wide-area Discovery", "help": "Wide-area discovery configuration group for exposing discovery signals beyond local-link scopes. Enable only in deployments that intentionally aggregate gateway presence across sites.", "hasChildren": true @@ -38401,9 +35626,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Wide-area Discovery Domain", "help": "Optional unicast DNS-SD domain for wide-area discovery, such as openclaw.internal. Use this when you intentionally publish gateway discovery beyond local mDNS scopes.", "hasChildren": false @@ -38415,9 +35638,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Wide-area Discovery Enabled", "help": "Enables wide-area discovery signaling when your environment needs non-local gateway discovery. Keep disabled unless cross-network discovery is operationally required.", "hasChildren": false @@ -38429,9 +35650,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Environment", "help": "Environment import and override settings used to supply runtime variables to the gateway process. Use this section to control shell-env loading and explicit variable injection behavior.", "hasChildren": true @@ -38453,9 +35672,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Shell Environment Import", "help": "Shell environment import controls for loading variables from your login shell during startup. Keep this enabled when you depend on profile-defined secrets or PATH customizations.", "hasChildren": true @@ -38467,9 +35684,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Shell Environment Import Enabled", "help": "Enables loading environment variables from the user shell profile during startup initialization. Keep enabled for developer machines, or disable in locked-down service environments with explicit env management.", "hasChildren": false @@ -38481,9 +35696,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Shell Environment Import Timeout (ms)", "help": "Maximum time in milliseconds allowed for shell environment resolution before fallback behavior applies. Use tighter timeouts for faster startup, or increase when shell initialization is heavy.", "hasChildren": false @@ -38495,9 +35708,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Environment Variable Overrides", "help": "Explicit key/value environment variable overrides merged into runtime process environment for OpenClaw. Use this for deterministic env configuration instead of relying only on shell profile side effects.", "hasChildren": true @@ -38519,9 +35730,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Gateway", "help": "Gateway runtime surface for bind mode, auth, control UI, remote transport, and operational safety controls. Keep conservative defaults unless you intentionally expose the gateway beyond trusted local interfaces.", "hasChildren": true @@ -38533,11 +35742,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "network", - "reliability" - ], + "tags": ["access", "network", "reliability"], "label": "Gateway Allow x-real-ip Fallback", "help": "Enables x-real-ip fallback when x-forwarded-for is missing in proxy scenarios. Keep disabled unless your ingress stack requires this compatibility behavior.", "hasChildren": false @@ -38549,9 +35754,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway Auth", "help": "Authentication policy for gateway HTTP/WebSocket access including mode, credentials, trusted-proxy behavior, and rate limiting. Keep auth enabled for every non-loopback deployment.", "hasChildren": true @@ -38563,10 +35766,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "network" - ], + "tags": ["access", "network"], "label": "Gateway Auth Allow Tailscale Identity", "help": "Allows trusted Tailscale identity paths to satisfy gateway auth checks when configured. Use this only when your tailnet identity posture is strong and operator workflows depend on it.", "hasChildren": false @@ -38578,9 +35778,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway Auth Mode", "help": "Gateway auth mode: \"none\", \"token\", \"password\", or \"trusted-proxy\" depending on your edge architecture. Use token/password for direct exposure, and trusted-proxy only behind hardened identity-aware proxies.", "hasChildren": false @@ -38588,19 +35786,11 @@ { "path": "gateway.auth.password", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "access", - "auth", - "network", - "security" - ], + "tags": ["access", "auth", "network", "security"], "label": "Gateway Password", "help": "Required for Tailscale funnel.", "hasChildren": true @@ -38642,10 +35832,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network", - "performance" - ], + "tags": ["network", "performance"], "label": "Gateway Auth Rate Limit", "help": "Login/auth attempt throttling controls to reduce credential brute-force risk at the gateway boundary. Keep enabled in exposed environments and tune thresholds to your traffic baseline.", "hasChildren": true @@ -38693,19 +35880,11 @@ { "path": "gateway.auth.token", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "access", - "auth", - "network", - "security" - ], + "tags": ["access", "auth", "network", "security"], "label": "Gateway Token", "help": "Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.", "hasChildren": true @@ -38747,9 +35926,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway Trusted Proxy Auth", "help": "Trusted-proxy auth header mapping for upstream identity providers that inject user claims. Use only with known proxy CIDRs and strict header allowlists to prevent spoofed identity headers.", "hasChildren": true @@ -38811,9 +35988,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway Bind Mode", "help": "Network bind profile: \"auto\", \"lan\", \"loopback\", \"custom\", or \"tailnet\" to control interface exposure. Keep \"loopback\" or \"auto\" for safest local operation unless external clients must connect.", "hasChildren": false @@ -38825,10 +36000,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network", - "reliability" - ], + "tags": ["network", "reliability"], "label": "Gateway Channel Health Check Interval (min)", "help": "Interval in minutes for automatic channel health probing and status updates. Use lower intervals for faster detection, or higher intervals to reduce periodic probe noise.", "hasChildren": false @@ -38840,10 +36012,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network", - "performance" - ], + "tags": ["network", "performance"], "label": "Gateway Channel Max Restarts Per Hour", "help": "Maximum number of health-monitor-initiated channel restarts allowed within a rolling one-hour window. Once hit, further restarts are skipped until the window expires. Default: 10.", "hasChildren": false @@ -38855,9 +36024,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway Channel Stale Event Threshold (min)", "help": "How many minutes a connected channel can go without receiving any event before the health monitor treats it as a stale socket and triggers a restart. Default: 30.", "hasChildren": false @@ -38869,9 +36036,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Control UI", "help": "Control UI hosting settings including enablement, pathing, and browser-origin/auth hardening behavior. Keep UI exposure minimal and pair with strong auth controls before internet-facing deployments.", "hasChildren": true @@ -38883,10 +36048,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "network" - ], + "tags": ["access", "network"], "label": "Control UI Allowed Origins", "help": "Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled.", "hasChildren": true @@ -38908,12 +36070,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "advanced", - "network", - "security" - ], + "tags": ["access", "advanced", "network", "security"], "label": "Insecure Control UI Auth Toggle", "help": "Loosens strict browser auth checks for Control UI when you must run a non-standard setup. Keep this off unless you trust your network and proxy path, because impersonation risk is higher.", "hasChildren": false @@ -38925,10 +36082,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network", - "storage" - ], + "tags": ["network", "storage"], "label": "Control UI Base Path", "help": "Optional URL prefix where the Control UI is served (e.g. /openclaw).", "hasChildren": false @@ -38940,12 +36094,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "advanced", - "network", - "security" - ], + "tags": ["access", "advanced", "network", "security"], "label": "Dangerously Allow Host-Header Origin Fallback", "help": "DANGEROUS toggle that enables Host-header based origin fallback for Control UI/WebChat websocket checks. This mode is supported when your deployment intentionally relies on Host-header origin policy; explicit gateway.controlUi.allowedOrigins remains the recommended hardened default.", "hasChildren": false @@ -38957,12 +36106,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "advanced", - "network", - "security" - ], + "tags": ["access", "advanced", "network", "security"], "label": "Dangerously Disable Control UI Device Auth", "help": "Disables Control UI device identity checks and relies on token/password only. Use only for short-lived debugging on trusted networks, then turn it off immediately.", "hasChildren": false @@ -38974,9 +36118,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Control UI Enabled", "help": "Enables serving the gateway Control UI from the gateway HTTP process when true. Keep enabled for local administration, and disable when an external control surface replaces it.", "hasChildren": false @@ -38988,9 +36130,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Control UI Assets Root", "help": "Optional filesystem root for Control UI assets (defaults to dist/control-ui).", "hasChildren": false @@ -39002,9 +36142,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway Custom Bind Host", "help": "Explicit bind host/IP used when gateway.bind is set to custom for manual interface targeting. Use a precise address and avoid wildcard binds unless external exposure is required.", "hasChildren": false @@ -39016,9 +36154,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway HTTP API", "help": "Gateway HTTP API configuration grouping endpoint toggles and transport-facing API exposure controls. Keep only required endpoints enabled to reduce attack surface.", "hasChildren": true @@ -39030,9 +36166,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway HTTP Endpoints", "help": "HTTP endpoint feature toggles under the gateway API surface for compatibility routes and optional integrations. Enable endpoints intentionally and monitor access patterns after rollout.", "hasChildren": true @@ -39054,9 +36188,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "OpenAI Chat Completions Endpoint", "help": "Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).", "hasChildren": false @@ -39068,10 +36200,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "network" - ], + "tags": ["media", "network"], "label": "OpenAI Chat Completions Image Limits", "help": "Image fetch/validation controls for OpenAI-compatible `image_url` parts.", "hasChildren": true @@ -39083,11 +36212,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "media", - "network" - ], + "tags": ["access", "media", "network"], "label": "OpenAI Chat Completions Image MIME Allowlist", "help": "Allowed MIME types for `image_url` parts (case-insensitive list).", "hasChildren": true @@ -39109,11 +36234,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "media", - "network" - ], + "tags": ["access", "media", "network"], "label": "OpenAI Chat Completions Allow Image URLs", "help": "Allow server-side URL fetches for `image_url` parts (default: false; data URIs remain supported).", "hasChildren": false @@ -39125,11 +36246,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "network", - "performance" - ], + "tags": ["media", "network", "performance"], "label": "OpenAI Chat Completions Image Max Bytes", "help": "Max bytes per fetched/decoded `image_url` image (default: 10MB).", "hasChildren": false @@ -39141,12 +36258,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "network", - "performance", - "storage" - ], + "tags": ["media", "network", "performance", "storage"], "label": "OpenAI Chat Completions Image Max Redirects", "help": "Max HTTP redirects allowed when fetching `image_url` URLs (default: 3).", "hasChildren": false @@ -39158,11 +36270,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "network", - "performance" - ], + "tags": ["media", "network", "performance"], "label": "OpenAI Chat Completions Image Timeout (ms)", "help": "Timeout in milliseconds for `image_url` URL fetches (default: 10000).", "hasChildren": false @@ -39174,11 +36282,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "media", - "network" - ], + "tags": ["access", "media", "network"], "label": "OpenAI Chat Completions Image URL Allowlist", "help": "Optional hostname allowlist for `image_url` URL fetches; supports exact hosts and `*.example.com` wildcards.", "hasChildren": true @@ -39200,10 +36304,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network", - "performance" - ], + "tags": ["network", "performance"], "label": "OpenAI Chat Completions Max Body Bytes", "help": "Max request body size in bytes for `/v1/chat/completions` (default: 20MB).", "hasChildren": false @@ -39215,11 +36316,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "network", - "performance" - ], + "tags": ["media", "network", "performance"], "label": "OpenAI Chat Completions Max Image Parts", "help": "Max number of `image_url` parts accepted from the latest user message (default: 8).", "hasChildren": false @@ -39231,11 +36328,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "network", - "performance" - ], + "tags": ["media", "network", "performance"], "label": "OpenAI Chat Completions Max Total Image Bytes", "help": "Max cumulative decoded bytes across all `image_url` parts in one request (default: 20MB).", "hasChildren": false @@ -39517,9 +36610,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway HTTP Security Headers", "help": "Optional HTTP response security headers applied by the gateway process itself. Prefer setting these at your reverse proxy when TLS terminates there.", "hasChildren": true @@ -39527,16 +36618,11 @@ { "path": "gateway.http.securityHeaders.strictTransportSecurity", "kind": "core", - "type": [ - "boolean", - "string" - ], + "type": ["boolean", "string"], "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Strict Transport Security Header", "help": "Value for the Strict-Transport-Security response header. Set only on HTTPS origins that you fully control; use false to explicitly disable.", "hasChildren": false @@ -39548,9 +36634,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway Mode", "help": "Gateway operation mode: \"local\" runs channels and agent runtime on this host, while \"remote\" connects through remote transport. Keep \"local\" unless you intentionally run a split remote gateway topology.", "hasChildren": false @@ -39572,10 +36656,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "network" - ], + "tags": ["access", "network"], "label": "Gateway Node Allowlist (Extra Commands)", "help": "Extra node.invoke commands to allow beyond the gateway defaults (array of command strings). Enabling dangerous commands here is a security-sensitive override and is flagged by `openclaw security audit`.", "hasChildren": true @@ -39607,9 +36688,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway Node Browser Mode", "help": "Node browser routing (\"auto\" = pick single connected browser node, \"manual\" = require node param, \"off\" = disable).", "hasChildren": false @@ -39621,9 +36700,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway Node Browser Pin", "help": "Pin browser routing to a specific node id or name (optional).", "hasChildren": false @@ -39635,10 +36712,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "network" - ], + "tags": ["access", "network"], "label": "Gateway Node Denylist", "help": "Node command names to block even if present in node claims or default allowlist (exact command-name matching only, e.g. `system.run`; does not inspect shell text inside that command).", "hasChildren": true @@ -39660,9 +36734,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway Port", "help": "TCP port used by the gateway listener for API, control UI, and channel-facing ingress paths. Use a dedicated port and avoid collisions with reverse proxies or local developer services.", "hasChildren": false @@ -39674,9 +36746,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway Push Delivery", "help": "Push-delivery settings used by the gateway when it needs to wake or notify paired devices. Configure relay-backed APNs here for official iOS builds; direct APNs auth remains env-based for local/manual builds.", "hasChildren": true @@ -39688,9 +36758,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway APNs Delivery", "help": "APNs delivery settings for iOS devices paired to this gateway. Use relay settings for official/TestFlight builds that register through the external push relay.", "hasChildren": true @@ -39702,9 +36770,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway APNs Relay", "help": "External relay settings for relay-backed APNs sends. The gateway uses this relay for push.test, wake nudges, and reconnect wakes after a paired official iOS build publishes a relay-backed registration.", "hasChildren": true @@ -39716,10 +36782,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced", - "network" - ], + "tags": ["advanced", "network"], "label": "Gateway APNs Relay Base URL", "help": "Base HTTPS URL for the external APNs relay service used by official/TestFlight iOS builds. Keep this aligned with the relay URL baked into the iOS build so registration and send traffic hit the same deployment.", "hasChildren": false @@ -39731,10 +36794,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network", - "performance" - ], + "tags": ["network", "performance"], "label": "Gateway APNs Relay Timeout (ms)", "help": "Timeout in milliseconds for relay send requests from the gateway to the APNs relay (default: 10000). Increase for slower relays or networks, or lower to fail wake attempts faster.", "hasChildren": false @@ -39746,10 +36806,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network", - "reliability" - ], + "tags": ["network", "reliability"], "label": "Config Reload", "help": "Live config-reload policy for how edits are applied and when full restarts are triggered. Keep hybrid behavior for safest operational updates unless debugging reload internals.", "hasChildren": true @@ -39761,11 +36818,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network", - "performance", - "reliability" - ], + "tags": ["network", "performance", "reliability"], "label": "Config Reload Debounce (ms)", "help": "Debounce window (ms) before applying config changes.", "hasChildren": false @@ -39777,10 +36830,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network", - "reliability" - ], + "tags": ["network", "reliability"], "label": "Config Reload Mode", "help": "Controls how config edits are applied: \"off\" ignores live edits, \"restart\" always restarts, \"hot\" applies in-process, and \"hybrid\" tries hot then restarts if required. Keep \"hybrid\" for safest routine updates.", "hasChildren": false @@ -39792,9 +36842,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Remote Gateway", "help": "Remote gateway connection settings for direct or SSH transport when this instance proxies to another runtime host. Use remote mode only when split-host operation is intentionally configured.", "hasChildren": true @@ -39802,18 +36850,11 @@ { "path": "gateway.remote.password", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "network", - "security" - ], + "tags": ["auth", "network", "security"], "label": "Remote Gateway Password", "help": "Password credential used for remote gateway authentication when password mode is enabled. Keep this secret managed externally and avoid plaintext values in committed config.", "hasChildren": true @@ -39855,9 +36896,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Remote Gateway SSH Identity", "help": "Optional SSH identity file path (passed to ssh -i).", "hasChildren": false @@ -39869,9 +36908,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Remote Gateway SSH Target", "help": "Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.", "hasChildren": false @@ -39883,11 +36920,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "auth", - "network", - "security" - ], + "tags": ["auth", "network", "security"], "label": "Remote Gateway TLS Fingerprint", "help": "Expected sha256 TLS fingerprint for the remote gateway (pin to avoid MITM).", "hasChildren": false @@ -39895,18 +36928,11 @@ { "path": "gateway.remote.token", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "network", - "security" - ], + "tags": ["auth", "network", "security"], "label": "Remote Gateway Token", "help": "Bearer token used to authenticate this client to a remote gateway in token-auth deployments. Store via secret/env substitution and rotate alongside remote gateway auth changes.", "hasChildren": true @@ -39948,9 +36974,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Remote Gateway Transport", "help": "Remote connection transport: \"direct\" uses configured URL connectivity, while \"ssh\" tunnels through SSH. Use SSH when you need encrypted tunnel semantics without exposing remote ports.", "hasChildren": false @@ -39962,9 +36986,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Remote Gateway URL", "help": "Remote Gateway WebSocket URL (ws:// or wss://).", "hasChildren": false @@ -39976,9 +36998,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway Tailscale", "help": "Tailscale integration settings for Serve/Funnel exposure and lifecycle handling on gateway start/exit. Keep off unless your deployment intentionally relies on Tailscale ingress.", "hasChildren": true @@ -39990,9 +37010,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway Tailscale Mode", "help": "Tailscale publish mode: \"off\", \"serve\", or \"funnel\" for private or public exposure paths. Use \"serve\" for tailnet-only access and \"funnel\" only when public internet reachability is required.", "hasChildren": false @@ -40004,9 +37022,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway Tailscale Reset on Exit", "help": "Resets Tailscale Serve/Funnel state on gateway exit to avoid stale published routes after shutdown. Keep enabled unless another controller manages publish lifecycle outside the gateway.", "hasChildren": false @@ -40018,9 +37034,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway TLS", "help": "TLS certificate and key settings for terminating HTTPS directly in the gateway process. Use explicit certificates in production and avoid plaintext exposure on untrusted networks.", "hasChildren": true @@ -40032,9 +37046,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway TLS Auto-Generate Cert", "help": "Auto-generates a local TLS certificate/key pair when explicit files are not configured. Use only for local/dev setups and replace with real certificates for production traffic.", "hasChildren": false @@ -40046,10 +37058,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network", - "storage" - ], + "tags": ["network", "storage"], "label": "Gateway TLS CA Path", "help": "Optional CA bundle path for client verification or custom trust-chain requirements at the gateway edge. Use this when private PKI or custom certificate chains are part of deployment.", "hasChildren": false @@ -40061,10 +37070,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network", - "storage" - ], + "tags": ["network", "storage"], "label": "Gateway TLS Certificate Path", "help": "Filesystem path to the TLS certificate file used by the gateway when TLS is enabled. Use managed certificate paths and keep renewal automation aligned with this location.", "hasChildren": false @@ -40076,9 +37082,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway TLS Enabled", "help": "Enables TLS termination at the gateway listener so clients connect over HTTPS/WSS directly. Keep enabled for direct internet exposure or any untrusted network boundary.", "hasChildren": false @@ -40090,10 +37094,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network", - "storage" - ], + "tags": ["network", "storage"], "label": "Gateway TLS Key Path", "help": "Filesystem path to the TLS private key file used by the gateway when TLS is enabled. Keep this key file permission-restricted and rotate per your security policy.", "hasChildren": false @@ -40105,9 +37106,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway Tool Exposure Policy", "help": "Gateway-level tool exposure allow/deny policy that can restrict runtime tool availability independent of agent/tool profiles. Use this for coarse emergency controls and production hardening.", "hasChildren": true @@ -40119,10 +37118,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "network" - ], + "tags": ["access", "network"], "label": "Gateway Tool Allowlist", "help": "Explicit gateway-level tool allowlist when you want a narrow set of tools available at runtime. Use this for locked-down environments where tool scope must be tightly controlled.", "hasChildren": true @@ -40144,10 +37140,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "network" - ], + "tags": ["access", "network"], "label": "Gateway Tool Denylist", "help": "Explicit gateway-level tool denylist to block risky tools even if lower-level policies allow them. Use deny rules for emergency response and defense-in-depth hardening.", "hasChildren": true @@ -40169,9 +37162,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Gateway Trusted Proxy CIDRs", "help": "CIDR/IP allowlist of upstream proxies permitted to provide forwarded client identity headers. Keep this list narrow so untrusted hops cannot impersonate users.", "hasChildren": true @@ -40193,9 +37184,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Hooks", "help": "Inbound webhook automation surface for mapping external events into wake or agent actions in OpenClaw. Keep this locked down with explicit token/session/agent controls before exposing it beyond trusted networks.", "hasChildren": true @@ -40207,9 +37196,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Hooks Allowed Agent IDs", "help": "Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents.", "hasChildren": true @@ -40231,10 +37218,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "storage" - ], + "tags": ["access", "storage"], "label": "Hooks Allowed Session Key Prefixes", "help": "Allowlist of accepted session-key prefixes for inbound hook requests when caller-provided keys are enabled. Use narrow prefixes to prevent arbitrary session-key injection.", "hasChildren": true @@ -40256,10 +37240,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "storage" - ], + "tags": ["access", "storage"], "label": "Hooks Allow Request Session Key", "help": "Allows callers to supply a session key in hook requests when true, enabling caller-controlled routing. Keep false unless trusted integrators explicitly need custom session threading.", "hasChildren": false @@ -40271,9 +37252,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Hooks Default Session Key", "help": "Fallback session key used for hook deliveries when a request does not provide one through allowed channels. Use a stable but scoped key to avoid mixing unrelated automation conversations.", "hasChildren": false @@ -40285,9 +37264,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Hooks Enabled", "help": "Enables the hooks endpoint and mapping execution pipeline for inbound webhook requests. Keep disabled unless you are actively routing external events into the gateway.", "hasChildren": false @@ -40299,9 +37276,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Gmail Hook", "help": "Gmail push integration settings used for Pub/Sub notifications and optional local callback serving. Keep this scoped to dedicated Gmail automation accounts where possible.", "hasChildren": true @@ -40313,9 +37288,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Gmail Hook Account", "help": "Google account identifier used for Gmail watch/subscription operations in this hook integration. Use a dedicated automation mailbox account to isolate operational permissions.", "hasChildren": false @@ -40327,9 +37300,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Gmail Hook Allow Unsafe External Content", "help": "Allows less-sanitized external Gmail content to pass into processing when enabled. Keep disabled for safer defaults, and enable only for trusted mail streams with controlled transforms.", "hasChildren": false @@ -40341,9 +37312,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Gmail Hook Callback URL", "help": "Public callback URL Gmail or intermediaries invoke to deliver notifications into this hook pipeline. Keep this URL protected with token validation and restricted network exposure.", "hasChildren": false @@ -40355,9 +37324,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Gmail Hook Include Body", "help": "When true, fetch and include email body content for downstream mapping/agent processing. Keep false unless body text is required, because this increases payload size and sensitivity.", "hasChildren": false @@ -40369,9 +37336,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Gmail Hook Label", "help": "Optional Gmail label filter limiting which labeled messages trigger hook events. Keep filters narrow to avoid flooding automations with unrelated inbox traffic.", "hasChildren": false @@ -40383,9 +37348,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Gmail Hook Max Body Bytes", "help": "Maximum Gmail payload bytes processed per event when includeBody is enabled. Keep conservative limits to reduce oversized message processing cost and risk.", "hasChildren": false @@ -40397,9 +37360,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models" - ], + "tags": ["models"], "label": "Gmail Hook Model Override", "help": "Optional model override for Gmail-triggered runs when mailbox automations should use dedicated model behavior. Keep unset to inherit agent defaults unless mailbox tasks need specialization.", "hasChildren": false @@ -40411,10 +37372,7 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "security" - ], + "tags": ["auth", "security"], "label": "Gmail Hook Push Token", "help": "Shared secret token required on Gmail push hook callbacks before processing notifications. Use env substitution and rotate if callback endpoints are exposed externally.", "hasChildren": false @@ -40426,9 +37384,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Gmail Hook Renew Interval (min)", "help": "Renewal cadence in minutes for Gmail watch subscriptions to prevent expiration. Set below provider expiration windows and monitor renew failures in logs.", "hasChildren": false @@ -40440,9 +37396,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Gmail Hook Local Server", "help": "Local callback server settings block for directly receiving Gmail notifications without a separate ingress layer. Enable only when this process should terminate webhook traffic itself.", "hasChildren": true @@ -40454,9 +37408,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Gmail Hook Server Bind Address", "help": "Bind address for the local Gmail callback HTTP server used when serving hooks directly. Keep loopback-only unless external ingress is intentionally required.", "hasChildren": false @@ -40468,9 +37420,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Gmail Hook Server Path", "help": "HTTP path on the local Gmail callback server where push notifications are accepted. Keep this consistent with subscription configuration to avoid dropped events.", "hasChildren": false @@ -40482,9 +37432,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Gmail Hook Server Port", "help": "Port for the local Gmail callback HTTP server when serve mode is enabled. Use a dedicated port to avoid collisions with gateway/control interfaces.", "hasChildren": false @@ -40496,9 +37444,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Gmail Hook Subscription", "help": "Pub/Sub subscription consumed by the gateway to receive Gmail change notifications from the configured topic. Keep subscription ownership clear so multiple consumers do not race unexpectedly.", "hasChildren": false @@ -40510,9 +37456,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Gmail Hook Tailscale", "help": "Tailscale exposure configuration block for publishing Gmail callbacks through Serve/Funnel routes. Use private tailnet modes before enabling any public ingress path.", "hasChildren": true @@ -40524,9 +37468,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Gmail Hook Tailscale Mode", "help": "Tailscale exposure mode for Gmail callbacks: \"off\", \"serve\", or \"funnel\". Use \"serve\" for private tailnet delivery and \"funnel\" only when public internet ingress is required.", "hasChildren": false @@ -40538,9 +37480,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Gmail Hook Tailscale Path", "help": "Path published by Tailscale Serve/Funnel for Gmail callback forwarding when enabled. Keep it aligned with Gmail webhook config so requests reach the expected handler.", "hasChildren": false @@ -40552,9 +37492,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Gmail Hook Tailscale Target", "help": "Local service target forwarded by Tailscale Serve/Funnel (for example http://127.0.0.1:8787). Use explicit loopback targets to avoid ambiguous routing.", "hasChildren": false @@ -40566,9 +37504,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Gmail Hook Thinking Override", "help": "Thinking effort override for Gmail-driven agent runs: \"off\", \"minimal\", \"low\", \"medium\", or \"high\". Keep modest defaults for routine inbox automations to control cost and latency.", "hasChildren": false @@ -40580,9 +37516,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Gmail Hook Pub/Sub Topic", "help": "Google Pub/Sub topic name used by Gmail watch to publish change notifications for this account. Ensure the topic IAM grants Gmail publish access before enabling watches.", "hasChildren": false @@ -40594,9 +37528,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Internal Hooks", "help": "Internal hook runtime settings for bundled/custom event handlers loaded from module paths. Use this for trusted in-process automations and keep handler loading tightly scoped.", "hasChildren": true @@ -40608,9 +37540,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Internal Hooks Enabled", "help": "Enables processing for internal hook handlers and configured entries in the internal hook runtime. Keep disabled unless internal hook handlers are intentionally configured.", "hasChildren": false @@ -40622,9 +37552,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Internal Hook Entries", "help": "Configured internal hook entry records used to register concrete runtime handlers and metadata. Keep entries explicit and versioned so production behavior is auditable.", "hasChildren": true @@ -40685,9 +37613,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Internal Hook Handlers", "help": "List of internal event handlers mapping event names to modules and optional exports. Keep handler definitions explicit so event-to-code routing is auditable.", "hasChildren": true @@ -40709,9 +37635,7 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Internal Hook Event", "help": "Internal event name that triggers this handler module when emitted by the runtime. Use stable event naming conventions to avoid accidental overlap across handlers.", "hasChildren": false @@ -40723,9 +37647,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Internal Hook Export", "help": "Optional named export for the internal hook handler function when module default export is not used. Set this when one module ships multiple handler entrypoints.", "hasChildren": false @@ -40737,9 +37659,7 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Internal Hook Module", "help": "Safe relative module path for the internal hook handler implementation loaded at runtime. Keep module files in reviewed directories and avoid dynamic path composition.", "hasChildren": false @@ -40751,9 +37671,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Internal Hook Install Records", "help": "Install metadata for internal hook modules, including source and resolved artifacts for repeatable deployments. Use this as operational provenance and avoid manual drift edits.", "hasChildren": true @@ -40915,9 +37833,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Internal Hook Loader", "help": "Internal hook loader settings controlling where handler modules are discovered at startup. Use constrained load roots to reduce accidental module conflicts or shadowing.", "hasChildren": true @@ -40929,9 +37845,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Internal Hook Extra Directories", "help": "Additional directories searched for internal hook modules beyond default load paths. Keep this minimal and controlled to reduce accidental module shadowing.", "hasChildren": true @@ -40953,9 +37867,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Hook Mappings", "help": "Ordered mapping rules that match inbound hook requests and choose wake or agent actions with optional delivery routing. Use specific mappings first to avoid broad pattern rules capturing everything.", "hasChildren": true @@ -40977,9 +37889,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Hook Mapping Action", "help": "Mapping action type: \"wake\" triggers agent wake flow, while \"agent\" sends directly to agent handling. Use \"agent\" for immediate execution and \"wake\" when heartbeat-driven processing is preferred.", "hasChildren": false @@ -40991,9 +37901,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Hook Mapping Agent ID", "help": "Target agent ID for mapping execution when action routing should not use defaults. Use dedicated automation agents to isolate webhook behavior from interactive operator sessions.", "hasChildren": false @@ -41005,9 +37913,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Hook Mapping Allow Unsafe External Content", "help": "When true, mapping content may include less-sanitized external payload data in generated messages. Keep false by default and enable only for trusted sources with reviewed transform logic.", "hasChildren": false @@ -41019,9 +37925,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Hook Mapping Delivery Channel", "help": "Delivery channel override for mapping outputs (for example \"last\", \"telegram\", \"discord\", \"slack\", \"signal\", \"imessage\", or \"msteams\"). Keep channel overrides explicit to avoid accidental cross-channel sends.", "hasChildren": false @@ -41033,9 +37937,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Hook Mapping Deliver Reply", "help": "Controls whether mapping execution results are delivered back to a channel destination versus being processed silently. Disable delivery for background automations that should not post user-facing output.", "hasChildren": false @@ -41047,9 +37949,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Hook Mapping ID", "help": "Optional stable identifier for a hook mapping entry used for auditing, troubleshooting, and targeted updates. Use unique IDs so logs and config diffs can reference mappings unambiguously.", "hasChildren": false @@ -41061,9 +37961,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Hook Mapping Match", "help": "Grouping object for mapping match predicates such as path and source before action routing is applied. Keep match criteria specific so unrelated webhook traffic does not trigger automations.", "hasChildren": true @@ -41075,9 +37973,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Hook Mapping Match Path", "help": "Path match condition for a hook mapping, usually compared against the inbound request path. Use this to split automation behavior by webhook endpoint path families.", "hasChildren": false @@ -41089,9 +37985,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Hook Mapping Match Source", "help": "Source match condition for a hook mapping, typically set by trusted upstream metadata or adapter logic. Use stable source identifiers so routing remains deterministic across retries.", "hasChildren": false @@ -41103,9 +37997,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Hook Mapping Message Template", "help": "Template for synthesizing structured mapping input into the final message content sent to the target action path. Keep templates deterministic so downstream parsing and behavior remain stable.", "hasChildren": false @@ -41117,9 +38009,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models" - ], + "tags": ["models"], "label": "Hook Mapping Model Override", "help": "Optional model override for mapping-triggered runs when automation should use a different model than agent defaults. Use this sparingly so behavior remains predictable across mapping executions.", "hasChildren": false @@ -41131,9 +38021,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Hook Mapping Name", "help": "Human-readable mapping display name used in diagnostics and operator-facing config UIs. Keep names concise and descriptive so routing intent is obvious during incident review.", "hasChildren": false @@ -41145,10 +38033,7 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "security", - "storage" - ], + "tags": ["security", "storage"], "label": "Hook Mapping Session Key", "help": "Explicit session key override for mapping-delivered messages to control thread continuity. Use stable scoped keys so repeated events correlate without leaking into unrelated conversations.", "hasChildren": false @@ -41160,9 +38045,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Hook Mapping Text Template", "help": "Text-only fallback template used when rich payload rendering is not desired or not supported. Use this to provide a concise, consistent summary string for chat delivery surfaces.", "hasChildren": false @@ -41174,9 +38057,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Hook Mapping Thinking Override", "help": "Optional thinking-effort override for mapping-triggered runs to tune latency versus reasoning depth. Keep low or minimal for high-volume hooks unless deeper reasoning is clearly required.", "hasChildren": false @@ -41188,9 +38069,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Hook Mapping Timeout (sec)", "help": "Maximum runtime allowed for mapping action execution before timeout handling applies. Use tighter limits for high-volume webhook sources to prevent queue pileups.", "hasChildren": false @@ -41202,9 +38081,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Hook Mapping Delivery Destination", "help": "Destination identifier inside the selected channel when mapping replies should route to a fixed target. Verify provider-specific destination formats before enabling production mappings.", "hasChildren": false @@ -41216,9 +38093,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Hook Mapping Transform", "help": "Transform configuration block defining module/export preprocessing before mapping action handling. Use transforms only from reviewed code paths and keep behavior deterministic for repeatable automation.", "hasChildren": true @@ -41230,9 +38105,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Hook Transform Export", "help": "Named export to invoke from the transform module; defaults to module default export when omitted. Set this when one file hosts multiple transform handlers.", "hasChildren": false @@ -41244,9 +38117,7 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Hook Transform Module", "help": "Relative transform module path loaded from hooks.transformsDir to rewrite incoming payloads before delivery. Keep modules local, reviewed, and free of path traversal patterns.", "hasChildren": false @@ -41258,9 +38129,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Hook Mapping Wake Mode", "help": "Wake scheduling mode: \"now\" wakes immediately, while \"next-heartbeat\" defers until the next heartbeat cycle. Use deferred mode for lower-priority automations that can tolerate slight delay.", "hasChildren": false @@ -41272,9 +38141,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Hooks Max Body Bytes", "help": "Maximum accepted webhook payload size in bytes before the request is rejected. Keep this bounded to reduce abuse risk and protect memory usage under bursty integrations.", "hasChildren": false @@ -41286,9 +38153,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Hooks Endpoint Path", "help": "HTTP path used by the hooks endpoint (for example `/hooks`) on the gateway control server. Use a non-guessable path and combine it with token validation for defense in depth.", "hasChildren": false @@ -41300,9 +38165,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Hooks Presets", "help": "Named hook preset bundles applied at load time to seed standard mappings and behavior defaults. Keep preset usage explicit so operators can audit which automations are active.", "hasChildren": true @@ -41324,10 +38187,7 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "security" - ], + "tags": ["auth", "security"], "label": "Hooks Auth Token", "help": "Shared bearer token checked by hooks ingress for request authentication before mappings run. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.", "hasChildren": false @@ -41339,9 +38199,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Hooks Transforms Directory", "help": "Base directory for hook transform modules referenced by mapping transform.module paths. Use a controlled repo directory so dynamic imports remain reviewable and predictable.", "hasChildren": false @@ -41353,9 +38211,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Logging", "help": "Logging behavior controls for severity, output destinations, formatting, and sensitive-data redaction. Keep levels and redaction strict enough for production while preserving useful diagnostics.", "hasChildren": true @@ -41367,9 +38223,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability" - ], + "tags": ["observability"], "label": "Console Log Level", "help": "Console-specific log threshold: \"silent\", \"fatal\", \"error\", \"warn\", \"info\", \"debug\", or \"trace\" for terminal output control. Use this to keep local console quieter while retaining richer file logging if needed.", "hasChildren": false @@ -41381,9 +38235,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability" - ], + "tags": ["observability"], "label": "Console Log Style", "help": "Console output format style: \"pretty\", \"compact\", or \"json\" based on operator and ingestion needs. Use json for machine parsing pipelines and pretty/compact for human-first terminal workflows.", "hasChildren": false @@ -41395,10 +38247,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability", - "storage" - ], + "tags": ["observability", "storage"], "label": "Log File Path", "help": "Optional file path for persisted log output in addition to or instead of console logging. Use a managed writable path and align retention/rotation with your operational policy.", "hasChildren": false @@ -41410,9 +38259,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability" - ], + "tags": ["observability"], "label": "Log Level", "help": "Primary log level threshold for runtime logger output: \"silent\", \"fatal\", \"error\", \"warn\", \"info\", \"debug\", or \"trace\". Keep \"info\" or \"warn\" for production, and use debug/trace only during investigation.", "hasChildren": false @@ -41434,10 +38281,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability", - "privacy" - ], + "tags": ["observability", "privacy"], "label": "Custom Redaction Patterns", "help": "Additional custom redact regex patterns applied to log output before emission/storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.", "hasChildren": true @@ -41459,10 +38303,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability", - "privacy" - ], + "tags": ["observability", "privacy"], "label": "Sensitive Data Redaction Mode", "help": "Sensitive redaction mode: \"off\" disables built-in masking, while \"tools\" redacts sensitive tool/config payload fields. Keep \"tools\" in shared logs unless you have isolated secure log sinks.", "hasChildren": false @@ -41474,9 +38315,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Media", "help": "Top-level media behavior shared across providers and tools that handle inbound files. Keep defaults unless you need stable filenames for external processing pipelines or longer-lived inbound media retention.", "hasChildren": true @@ -41488,9 +38327,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Preserve Media Filenames", "help": "When enabled, uploaded media keeps its original filename instead of a generated temp-safe name. Turn this on when downstream automations depend on stable names, and leave off to reduce accidental filename leakage.", "hasChildren": false @@ -41502,9 +38339,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Media Retention TTL (hours)", "help": "Optional retention window in hours for persisted inbound media cleanup across the full media tree. Leave unset to preserve legacy behavior, or set values like 24 (1 day) or 168 (7 days) when you want automatic cleanup.", "hasChildren": false @@ -41516,9 +38351,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Memory", "help": "Memory backend configuration (global).", "hasChildren": true @@ -41530,9 +38363,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Memory Backend", "help": "Selects the global memory engine: \"builtin\" uses OpenClaw memory internals, while \"qmd\" uses the QMD sidecar pipeline. Keep \"builtin\" unless you intentionally operate QMD.", "hasChildren": false @@ -41544,9 +38375,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Memory Citations Mode", "help": "Controls citation visibility in replies: \"auto\" shows citations when useful, \"on\" always shows them, and \"off\" hides them. Keep \"auto\" for a balanced signal-to-noise default.", "hasChildren": false @@ -41568,9 +38397,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "QMD Binary", "help": "Sets the executable path for the `qmd` binary used by the QMD backend (default: resolved from PATH). Use an explicit absolute path when multiple qmd installs exist or PATH differs across environments.", "hasChildren": false @@ -41582,9 +38409,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "QMD Include Default Memory", "help": "Automatically indexes default memory files (MEMORY.md and memory/**/*.md) into QMD collections. Keep enabled unless you want indexing controlled only through explicit custom paths.", "hasChildren": false @@ -41606,10 +38431,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage" - ], + "tags": ["performance", "storage"], "label": "QMD Max Injected Chars", "help": "Caps how much QMD text can be injected into one turn across all hits. Use lower values to control prompt bloat and latency; raise only when context is consistently truncated.", "hasChildren": false @@ -41621,10 +38443,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage" - ], + "tags": ["performance", "storage"], "label": "QMD Max Results", "help": "Limits how many QMD hits are returned into the agent loop for each recall request (default: 6). Increase for broader recall context, or lower to keep prompts tighter and faster.", "hasChildren": false @@ -41636,10 +38455,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage" - ], + "tags": ["performance", "storage"], "label": "QMD Max Snippet Chars", "help": "Caps per-result snippet length extracted from QMD hits in characters (default: 700). Lower this when prompts bloat quickly, and raise only if answers consistently miss key details.", "hasChildren": false @@ -41651,10 +38467,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage" - ], + "tags": ["performance", "storage"], "label": "QMD Search Timeout (ms)", "help": "Sets per-query QMD search timeout in milliseconds (default: 4000). Increase for larger indexes or slower environments, and lower to keep request latency bounded.", "hasChildren": false @@ -41666,9 +38479,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "QMD MCPorter", "help": "Routes QMD work through mcporter (MCP runtime) instead of spawning `qmd` for each call. Use this when cold starts are expensive on large models; keep direct process mode for simpler local setups.", "hasChildren": true @@ -41680,9 +38491,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "QMD MCPorter Enabled", "help": "Routes QMD through an mcporter daemon instead of spawning qmd per request, reducing cold-start overhead for larger models. Keep disabled unless mcporter is installed and configured.", "hasChildren": false @@ -41694,9 +38503,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "QMD MCPorter Server Name", "help": "Names the mcporter server target used for QMD calls (default: qmd). Change only when your mcporter setup uses a custom server name for qmd mcp keep-alive.", "hasChildren": false @@ -41708,9 +38515,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "QMD MCPorter Start Daemon", "help": "Automatically starts the mcporter daemon when mcporter-backed QMD mode is enabled (default: true). Keep enabled unless process lifecycle is managed externally by your service supervisor.", "hasChildren": false @@ -41722,9 +38527,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "QMD Extra Paths", "help": "Adds custom directories or files to include in QMD indexing, each with an optional name and glob pattern. Use this for project-specific knowledge locations that are outside default memory paths.", "hasChildren": true @@ -41776,9 +38579,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "QMD Surface Scope", "help": "Defines which sessions/channels are eligible for QMD recall using session.sendPolicy-style rules. Keep default direct-only scope unless you intentionally want cross-chat memory sharing.", "hasChildren": true @@ -41880,9 +38681,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "QMD Search Mode", "help": "Selects the QMD retrieval path: \"query\" uses standard query flow, \"search\" uses search-oriented retrieval, and \"vsearch\" emphasizes vector retrieval. Keep default unless tuning relevance quality.", "hasChildren": false @@ -41904,9 +38703,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "QMD Session Indexing", "help": "Indexes session transcripts into QMD so recall can include prior conversation content (experimental, default: false). Enable only when transcript memory is required and you accept larger index churn.", "hasChildren": false @@ -41918,9 +38715,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "QMD Session Export Directory", "help": "Overrides where sanitized session exports are written before QMD indexing. Use this when default state storage is constrained or when exports must land on a managed volume.", "hasChildren": false @@ -41932,9 +38727,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "QMD Session Retention (days)", "help": "Defines how long exported session files are kept before automatic pruning, in days (default: unlimited). Set a finite value for storage hygiene or compliance retention policies.", "hasChildren": false @@ -41956,10 +38749,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage" - ], + "tags": ["performance", "storage"], "label": "QMD Command Timeout (ms)", "help": "Sets timeout for QMD maintenance commands such as collection list/add in milliseconds (default: 30000). Increase when running on slower disks or remote filesystems that delay command completion.", "hasChildren": false @@ -41971,10 +38761,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage" - ], + "tags": ["performance", "storage"], "label": "QMD Update Debounce (ms)", "help": "Sets the minimum delay between consecutive QMD refresh attempts in milliseconds (default: 15000). Increase this if frequent file changes cause update thrash or unnecessary background load.", "hasChildren": false @@ -41986,10 +38773,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage" - ], + "tags": ["performance", "storage"], "label": "QMD Embed Interval", "help": "Sets how often QMD recomputes embeddings (duration string, default: 60m; set 0 to disable periodic embeds). Lower intervals improve freshness but increase embedding workload and cost.", "hasChildren": false @@ -42001,10 +38785,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage" - ], + "tags": ["performance", "storage"], "label": "QMD Embed Timeout (ms)", "help": "Sets maximum runtime for each `qmd embed` cycle in milliseconds (default: 120000). Increase for heavier embedding workloads or slower hardware, and lower to fail fast under tight SLAs.", "hasChildren": false @@ -42016,10 +38797,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage" - ], + "tags": ["performance", "storage"], "label": "QMD Update Interval", "help": "Sets how often QMD refreshes indexes from source content (duration string, default: 5m). Shorter intervals improve freshness but increase background CPU and I/O.", "hasChildren": false @@ -42031,9 +38809,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "QMD Update on Startup", "help": "Runs an initial QMD update once during gateway startup (default: true). Keep enabled so recall starts from a fresh baseline; disable only when startup speed is more important than immediate freshness.", "hasChildren": false @@ -42045,10 +38821,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage" - ], + "tags": ["performance", "storage"], "label": "QMD Update Timeout (ms)", "help": "Sets maximum runtime for each `qmd update` cycle in milliseconds (default: 120000). Raise this for larger collections; lower it when you want quicker failure detection in automation.", "hasChildren": false @@ -42060,9 +38833,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "QMD Wait for Boot Sync", "help": "Blocks startup completion until the initial boot-time QMD sync finishes (default: false). Enable when you need fully up-to-date recall before serving traffic, and keep off for faster boot.", "hasChildren": false @@ -42074,9 +38845,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Messages", "help": "Message formatting, acknowledgment, queueing, debounce, and status reaction behavior for inbound/outbound chat flows. Use this section when channel responsiveness or message UX needs adjustment.", "hasChildren": true @@ -42088,9 +38857,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Ack Reaction Emoji", "help": "Emoji reaction used to acknowledge inbound messages (empty disables).", "hasChildren": false @@ -42100,19 +38867,10 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "group-mentions", - "group-all", - "direct", - "all", - "off", - "none" - ], + "enumValues": ["group-mentions", "group-all", "direct", "all", "off", "none"], "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Ack Reaction Scope", "help": "When to send ack reactions (\"group-mentions\", \"group-all\", \"direct\", \"all\", \"off\", \"none\"). \"off\"/\"none\" disables ack reactions entirely.", "hasChildren": false @@ -42124,9 +38882,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Group Chat Rules", "help": "Group-message handling controls including mention triggers and history window sizing. Keep mention patterns narrow so group channels do not trigger on every message.", "hasChildren": true @@ -42138,9 +38894,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Group History Limit", "help": "Maximum number of prior group messages loaded as context per turn for group sessions. Use higher values for richer continuity, or lower values for faster and cheaper responses.", "hasChildren": false @@ -42152,9 +38906,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Group Mention Patterns", "help": "Safe case-insensitive regex patterns used to detect explicit mentions/trigger phrases in group chats. Use precise patterns to reduce false positives in high-volume channels; invalid or unsafe nested-repetition patterns are ignored.", "hasChildren": true @@ -42176,9 +38928,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Inbound Debounce", "help": "Direct inbound debounce settings used before queue/turn processing starts. Configure this for provider-specific rapid message bursts from the same sender.", "hasChildren": true @@ -42190,9 +38940,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Inbound Debounce by Channel (ms)", "help": "Per-channel inbound debounce overrides keyed by provider id in milliseconds. Use this where some providers send message fragments more aggressively than others.", "hasChildren": true @@ -42214,9 +38962,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Inbound Message Debounce (ms)", "help": "Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).", "hasChildren": false @@ -42228,9 +38974,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Inbound Message Prefix", "help": "Prefix text prepended to inbound user messages before they are handed to the agent runtime. Use this sparingly for channel context markers and keep it stable across sessions.", "hasChildren": false @@ -42242,9 +38986,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Inbound Queue", "help": "Inbound message queue strategy used to buffer bursts before processing turns. Tune this for busy channels where sequential processing or batching behavior matters.", "hasChildren": true @@ -42256,9 +38998,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Queue Mode by Channel", "help": "Per-channel queue mode overrides keyed by provider id (for example telegram, discord, slack). Use this when one channel’s traffic pattern needs different queue behavior than global defaults.", "hasChildren": true @@ -42370,9 +39110,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Queue Capacity", "help": "Maximum number of queued inbound items retained before drop policy applies. Keep caps bounded in noisy channels so memory usage remains predictable.", "hasChildren": false @@ -42384,9 +39122,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Queue Debounce (ms)", "help": "Global queue debounce window in milliseconds before processing buffered inbound messages. Use higher values to coalesce rapid bursts, or lower values for reduced response latency.", "hasChildren": false @@ -42398,9 +39134,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Queue Debounce by Channel (ms)", "help": "Per-channel debounce overrides for queue behavior keyed by provider id. Use this to tune burst handling independently for chat surfaces with different pacing.", "hasChildren": true @@ -42422,9 +39156,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Queue Drop Strategy", "help": "Drop strategy when queue cap is exceeded: \"old\", \"new\", or \"summarize\". Use summarize when preserving intent matters, or old/new when deterministic dropping is preferred.", "hasChildren": false @@ -42436,9 +39168,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Queue Mode", "help": "Queue behavior mode: \"steer\", \"followup\", \"collect\", \"steer-backlog\", \"steer+backlog\", \"queue\", or \"interrupt\". Keep conservative modes unless you intentionally need aggressive interruption/backlog semantics.", "hasChildren": false @@ -42450,9 +39180,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Remove Ack Reaction After Reply", "help": "Removes the acknowledgment reaction after final reply delivery when enabled. Keep enabled for cleaner UX in channels where persistent ack reactions create clutter.", "hasChildren": false @@ -42464,9 +39192,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Outbound Response Prefix", "help": "Prefix text prepended to outbound assistant replies before sending to channels. Use for lightweight branding/context tags and avoid long prefixes that reduce content density.", "hasChildren": false @@ -42478,9 +39204,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Status Reactions", "help": "Lifecycle status reactions that update the emoji on the trigger message as the agent progresses (queued → thinking → tool → done/error).", "hasChildren": true @@ -42492,9 +39216,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Status Reaction Emojis", "help": "Override default status reaction emojis. Keys: thinking, compacting, tool, coding, web, done, error, stallSoft, stallHard. Must be valid Telegram reaction emojis.", "hasChildren": true @@ -42596,9 +39318,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable Status Reactions", "help": "Enable lifecycle status reactions for Telegram. When enabled, the ack reaction becomes the initial 'queued' state and progresses through thinking, tool, done/error automatically. Default: false.", "hasChildren": false @@ -42610,9 +39330,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Status Reaction Timing", "help": "Override default timing. Keys: debounceMs (700), stallSoftMs (25000), stallHardMs (60000), doneHoldMs (1500), errorHoldMs (2500).", "hasChildren": true @@ -42674,9 +39392,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Suppress Tool Error Warnings", "help": "When true, suppress ⚠️ tool-error warnings from being shown to the user. The agent already sees errors in context and can retry. Default: false.", "hasChildren": false @@ -42688,9 +39404,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media" - ], + "tags": ["media"], "label": "Message Text-to-Speech", "help": "Text-to-speech policy for reading agent replies aloud on supported voice or audio surfaces. Keep disabled unless voice playback is part of your operator/user workflow.", "hasChildren": true @@ -42700,12 +39414,7 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "off", - "always", - "inbound", - "tagged" - ], + "enumValues": ["off", "always", "inbound", "tagged"], "deprecated": false, "sensitive": false, "tags": [], @@ -42834,18 +39543,11 @@ { "path": "messages.tts.elevenlabs.apiKey", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "media", - "security" - ], + "tags": ["auth", "media", "security"], "hasChildren": true }, { @@ -42883,11 +39585,7 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "auto", - "on", - "off" - ], + "enumValues": ["auto", "on", "off"], "deprecated": false, "sensitive": false, "tags": [], @@ -43028,10 +39726,7 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "final", - "all" - ], + "enumValues": ["final", "all"], "deprecated": false, "sensitive": false, "tags": [], @@ -43140,18 +39835,11 @@ { "path": "messages.tts.openai.apiKey", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "media", - "security" - ], + "tags": ["auth", "media", "security"], "hasChildren": true }, { @@ -43249,11 +39937,7 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "elevenlabs", - "openai", - "edge" - ], + "enumValues": ["elevenlabs", "openai", "edge"], "deprecated": false, "sensitive": false, "tags": [], @@ -43286,9 +39970,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Metadata", "help": "Metadata fields automatically maintained by OpenClaw to record write/version history for this config file. Keep these values system-managed and avoid manual edits unless debugging migration history.", "hasChildren": true @@ -43300,9 +39982,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media" - ], + "tags": ["media"], "label": "Config Last Touched At", "help": "ISO timestamp of the last config write (auto-set).", "hasChildren": false @@ -43314,9 +39994,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media" - ], + "tags": ["media"], "label": "Config Last Touched Version", "help": "Auto-set when OpenClaw writes the config.", "hasChildren": false @@ -43328,9 +40006,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models" - ], + "tags": ["models"], "label": "Models", "help": "Model catalog root for provider definitions, merge/replace behavior, and optional Bedrock discovery integration. Keep provider definitions explicit and validated before relying on production failover paths.", "hasChildren": true @@ -43342,9 +40018,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models" - ], + "tags": ["models"], "label": "Bedrock Model Discovery", "help": "Automatic AWS Bedrock model discovery settings used to synthesize provider model entries from account visibility. Keep discovery scoped and refresh intervals conservative to reduce API churn.", "hasChildren": true @@ -43356,9 +40030,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models" - ], + "tags": ["models"], "label": "Bedrock Default Context Window", "help": "Fallback context-window value applied to discovered models when provider metadata lacks explicit limits. Use realistic defaults to avoid oversized prompts that exceed true provider constraints.", "hasChildren": false @@ -43370,12 +40042,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "auth", - "models", - "performance", - "security" - ], + "tags": ["auth", "models", "performance", "security"], "label": "Bedrock Default Max Tokens", "help": "Fallback max-token value applied to discovered models without explicit output token limits. Use conservative defaults to reduce truncation surprises and unexpected token spend.", "hasChildren": false @@ -43387,9 +40054,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models" - ], + "tags": ["models"], "label": "Bedrock Discovery Enabled", "help": "Enables periodic Bedrock model discovery and catalog refresh for Bedrock-backed providers. Keep disabled unless Bedrock is actively used and IAM permissions are correctly configured.", "hasChildren": false @@ -43401,9 +40066,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models" - ], + "tags": ["models"], "label": "Bedrock Discovery Provider Filter", "help": "Optional provider allowlist filter for Bedrock discovery so only selected providers are refreshed. Use this to limit discovery scope in multi-provider environments.", "hasChildren": true @@ -43425,10 +40088,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models", - "performance" - ], + "tags": ["models", "performance"], "label": "Bedrock Discovery Refresh Interval (s)", "help": "Refresh cadence for Bedrock discovery polling in seconds to detect newly available models over time. Use longer intervals in production to reduce API cost and control-plane noise.", "hasChildren": false @@ -43440,9 +40100,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models" - ], + "tags": ["models"], "label": "Bedrock Discovery Region", "help": "AWS region used for Bedrock discovery calls when discovery is enabled for your deployment. Use the region where your Bedrock models are provisioned to avoid empty discovery results.", "hasChildren": false @@ -43454,9 +40112,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models" - ], + "tags": ["models"], "label": "Model Catalog Mode", "help": "Controls provider catalog behavior: \"merge\" keeps built-ins and overlays your custom providers, while \"replace\" uses only your configured providers. In \"merge\", matching provider IDs preserve non-empty agent models.json baseUrl values, while apiKey values are preserved only when the provider is not SecretRef-managed in current config/auth-profile context; SecretRef-managed providers refresh apiKey from current source markers, and matching model contextWindow/maxTokens use the higher value between explicit and implicit entries.", "hasChildren": false @@ -43468,9 +40124,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models" - ], + "tags": ["models"], "label": "Model Providers", "help": "Provider map keyed by provider ID containing connection/auth settings and concrete model definitions. Use stable provider keys so references from agents and tooling remain portable across environments.", "hasChildren": true @@ -43502,9 +40156,7 @@ ], "deprecated": false, "sensitive": false, - "tags": [ - "models" - ], + "tags": ["models"], "label": "Model Provider API Adapter", "help": "Provider API adapter selection controlling request/response compatibility handling for model calls. Use the adapter that matches your upstream provider protocol to avoid feature mismatch.", "hasChildren": false @@ -43512,18 +40164,11 @@ { "path": "models.providers.*.apiKey", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "models", - "security" - ], + "tags": ["auth", "models", "security"], "label": "Model Provider API Key", "help": "Provider credential used for API-key based authentication when the provider requires direct key auth. Use secret/env substitution and avoid storing real keys in committed config files.", "hasChildren": true @@ -43565,9 +40210,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models" - ], + "tags": ["models"], "label": "Model Provider Auth Mode", "help": "Selects provider auth style: \"api-key\" for API key auth, \"token\" for bearer token auth, \"oauth\" for OAuth credentials, and \"aws-sdk\" for AWS credential resolution. Match this to your provider requirements.", "hasChildren": false @@ -43579,9 +40222,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models" - ], + "tags": ["models"], "label": "Model Provider Authorization Header", "help": "When true, credentials are sent via the HTTP Authorization header even if alternate auth is possible. Use this only when your provider or proxy explicitly requires Authorization forwarding.", "hasChildren": false @@ -43593,9 +40234,7 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": [ - "models" - ], + "tags": ["models"], "label": "Model Provider Base URL", "help": "Base URL for the provider endpoint used to serve model requests for that provider entry. Use HTTPS endpoints and keep URLs environment-specific through config templating where needed.", "hasChildren": false @@ -43607,9 +40246,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models" - ], + "tags": ["models"], "label": "Model Provider Headers", "help": "Static HTTP headers merged into provider requests for tenant routing, proxy auth, or custom gateway requirements. Use this sparingly and keep sensitive header values in secrets.", "hasChildren": true @@ -43617,17 +40254,11 @@ { "path": "models.providers.*.headers.*", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "models", - "security" - ], + "tags": ["models", "security"], "hasChildren": true }, { @@ -43667,9 +40298,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models" - ], + "tags": ["models"], "label": "Model Provider Inject num_ctx (OpenAI Compat)", "help": "Controls whether OpenClaw injects `options.num_ctx` for Ollama providers configured with the OpenAI-compatible adapter (`openai-completions`). Default is true. Set false only if your proxy/upstream rejects unknown `options` payload fields.", "hasChildren": false @@ -43681,9 +40310,7 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": [ - "models" - ], + "tags": ["models"], "label": "Model Provider Model List", "help": "Declared model list for a provider including identifiers, metadata, and optional compatibility/cost hints. Keep IDs exact to provider catalog values so selection and fallback resolve correctly.", "hasChildren": true @@ -44005,9 +40632,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Node Host", "help": "Node host controls for features exposed from this gateway node to other nodes or clients. Keep defaults unless you intentionally proxy local capabilities across your node network.", "hasChildren": true @@ -44019,9 +40644,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Node Browser Proxy", "help": "Groups browser-proxy settings for exposing local browser control through node routing. Enable only when remote node workflows need your local browser profiles.", "hasChildren": true @@ -44033,11 +40656,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "network", - "storage" - ], + "tags": ["access", "network", "storage"], "label": "Node Browser Proxy Allowed Profiles", "help": "Optional allowlist of browser profile names exposed through node proxy routing. Leave empty to expose all configured profiles, or use a tight list to enforce least-privilege profile access.", "hasChildren": true @@ -44059,9 +40678,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "network" - ], + "tags": ["network"], "label": "Node Browser Proxy Enabled", "help": "Expose the local browser control server through node proxy routing so remote clients can use this host's browser capabilities. Keep disabled unless remote automation explicitly depends on it.", "hasChildren": false @@ -44073,9 +40690,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugins", "help": "Plugin system controls for enabling extensions, constraining load scope, configuring entries, and tracking installs. Keep plugin policy explicit and least-privilege in production environments.", "hasChildren": true @@ -44087,9 +40702,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Plugin Allowlist", "help": "Optional allowlist of plugin IDs; when set, only listed plugins are eligible to load. Use this to enforce approved extension inventories in controlled environments.", "hasChildren": true @@ -44111,9 +40724,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Plugin Denylist", "help": "Optional denylist of plugin IDs that are blocked even if allowlists or paths include them. Use deny rules for emergency rollback and hard blocks on risky plugins.", "hasChildren": true @@ -44135,9 +40746,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable Plugins", "help": "Enable or disable plugin/extension loading globally during startup and config reload (default: true). Keep enabled only when extension capabilities are required by your deployment.", "hasChildren": false @@ -44149,9 +40758,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Entries", "help": "Per-plugin settings keyed by plugin ID including enablement and plugin-specific runtime configuration payloads. Use this for scoped plugin tuning without changing global loader policy.", "hasChildren": true @@ -44173,9 +40780,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Config", "help": "Plugin-defined configuration payload interpreted by that plugin's own schema and validation rules. Use only documented fields from the plugin to prevent ignored or invalid settings.", "hasChildren": true @@ -44196,9 +40801,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Enabled", "help": "Per-plugin enablement override for a specific entry, applied on top of global plugin policy (restart required). Use this to stage plugin rollout gradually across environments.", "hasChildren": false @@ -44210,9 +40813,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -44224,9 +40825,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -44238,9 +40837,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "ACPX Runtime", "help": "ACP runtime backend powered by acpx with configurable command path and version policy. (plugin: acpx)", "hasChildren": true @@ -44252,9 +40849,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "ACPX Runtime Config", "help": "Plugin-defined config payload for acpx.", "hasChildren": true @@ -44266,9 +40861,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "acpx Command", "help": "Optional path/command override for acpx (for example /home/user/repos/acpx/dist/cli.js). Leave unset to use plugin-local bundled acpx.", "hasChildren": false @@ -44280,9 +40873,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Default Working Directory", "help": "Default cwd for ACP session operations when not set per session.", "hasChildren": false @@ -44294,9 +40885,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Expected acpx Version", "help": "Exact version to enforce (for example 0.1.16) or \"any\" to skip strict version matching.", "hasChildren": false @@ -44308,9 +40897,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "MCP Servers", "help": "Named MCP server definitions to inject into ACPX-backed session bootstrap. Each entry needs a command and can include args and env.", "hasChildren": true @@ -44380,15 +40967,10 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "deny", - "fail" - ], + "enumValues": ["deny", "fail"], "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Non-Interactive Permission Policy", "help": "acpx policy when interactive permission prompts are unavailable.", "hasChildren": false @@ -44398,16 +40980,10 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "approve-all", - "approve-reads", - "deny-all" - ], + "enumValues": ["approve-all", "approve-reads", "deny-all"], "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Permission Mode", "help": "Default acpx permission policy for runtime prompts.", "hasChildren": false @@ -44419,10 +40995,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "advanced" - ], + "tags": ["access", "advanced"], "label": "Queue Owner TTL Seconds", "help": "Idle queue-owner TTL for acpx prompt turns. Keep this short in OpenClaw to avoid delayed completion after each turn.", "hasChildren": false @@ -44434,9 +41007,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Strict Windows cmd Wrapper", "help": "Enabled by default. On Windows, reject unresolved .cmd/.bat wrappers instead of shell fallback. Disable only for compatibility with non-standard wrappers.", "hasChildren": false @@ -44448,10 +41019,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced", - "performance" - ], + "tags": ["advanced", "performance"], "label": "Prompt Timeout Seconds", "help": "Optional acpx timeout for each runtime turn.", "hasChildren": false @@ -44463,9 +41031,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable ACPX Runtime", "hasChildren": false }, @@ -44476,9 +41042,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -44490,9 +41054,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -44504,9 +41066,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/bluebubbles", "help": "OpenClaw BlueBubbles channel plugin (plugin: bluebubbles)", "hasChildren": true @@ -44518,9 +41078,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/bluebubbles Config", "help": "Plugin-defined config payload for bluebubbles.", "hasChildren": false @@ -44532,9 +41090,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/bluebubbles", "hasChildren": false }, @@ -44545,9 +41101,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -44559,9 +41113,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -44573,9 +41125,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/copilot-proxy", "help": "OpenClaw Copilot Proxy provider plugin (plugin: copilot-proxy)", "hasChildren": true @@ -44587,9 +41137,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/copilot-proxy Config", "help": "Plugin-defined config payload for copilot-proxy.", "hasChildren": false @@ -44601,9 +41149,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/copilot-proxy", "hasChildren": false }, @@ -44614,9 +41160,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -44628,9 +41172,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -44642,9 +41184,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Device Pairing", "help": "Generate setup codes and approve device pairing requests. (plugin: device-pair)", "hasChildren": true @@ -44656,9 +41196,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Device Pairing Config", "help": "Plugin-defined config payload for device-pair.", "hasChildren": true @@ -44670,9 +41208,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Gateway URL", "help": "Public WebSocket URL used for /pair setup codes (ws/wss or http/https).", "hasChildren": false @@ -44684,9 +41220,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable Device Pairing", "hasChildren": false }, @@ -44697,9 +41231,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -44711,9 +41243,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -44725,9 +41255,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability" - ], + "tags": ["observability"], "label": "@openclaw/diagnostics-otel", "help": "OpenClaw diagnostics OpenTelemetry exporter (plugin: diagnostics-otel)", "hasChildren": true @@ -44739,9 +41267,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability" - ], + "tags": ["observability"], "label": "@openclaw/diagnostics-otel Config", "help": "Plugin-defined config payload for diagnostics-otel.", "hasChildren": false @@ -44753,9 +41279,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "observability" - ], + "tags": ["observability"], "label": "Enable @openclaw/diagnostics-otel", "hasChildren": false }, @@ -44766,9 +41290,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -44780,9 +41302,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -44794,9 +41314,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Diffs", "help": "Read-only diff viewer and file renderer for agents. (plugin: diffs)", "hasChildren": true @@ -44808,9 +41326,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Diffs Config", "help": "Plugin-defined config payload for diffs.", "hasChildren": true @@ -44833,9 +41349,7 @@ "defaultValue": true, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Default Background Highlights", "help": "Show added/removed background highlights by default.", "hasChildren": false @@ -44845,17 +41359,11 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "bars", - "classic", - "none" - ], + "enumValues": ["bars", "classic", "none"], "defaultValue": "bars", "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Diff Indicator Style", "help": "Choose added/removed indicators style.", "hasChildren": false @@ -44865,16 +41373,11 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "png", - "pdf" - ], + "enumValues": ["png", "pdf"], "defaultValue": "png", "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Default File Format", "help": "Rendered file format for file mode (PNG or PDF).", "hasChildren": false @@ -44887,10 +41390,7 @@ "defaultValue": 960, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage" - ], + "tags": ["performance", "storage"], "label": "Default File Max Width", "help": "Maximum file render width in CSS pixels.", "hasChildren": false @@ -44900,17 +41400,11 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "standard", - "hq", - "print" - ], + "enumValues": ["standard", "hq", "print"], "defaultValue": "standard", "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Default File Quality", "help": "Quality preset for PNG/PDF rendering.", "hasChildren": false @@ -44923,9 +41417,7 @@ "defaultValue": 2, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Default File Scale", "help": "Device scale factor used while rendering file artifacts.", "hasChildren": false @@ -44938,9 +41430,7 @@ "defaultValue": "Fira Code", "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Default Font", "help": "Preferred font family name for diff content and headers.", "hasChildren": false @@ -44953,9 +41443,7 @@ "defaultValue": 15, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Default Font Size", "help": "Base diff font size in pixels.", "hasChildren": false @@ -44965,10 +41453,7 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "png", - "pdf" - ], + "enumValues": ["png", "pdf"], "deprecated": false, "sensitive": false, "tags": [], @@ -44979,10 +41464,7 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "png", - "pdf" - ], + "enumValues": ["png", "pdf"], "deprecated": false, "sensitive": false, "tags": [], @@ -45003,11 +41485,7 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "standard", - "hq", - "print" - ], + "enumValues": ["standard", "hq", "print"], "deprecated": false, "sensitive": false, "tags": [], @@ -45028,16 +41506,11 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "unified", - "split" - ], + "enumValues": ["unified", "split"], "defaultValue": "unified", "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Default Layout", "help": "Initial diff layout shown in the viewer.", "hasChildren": false @@ -45050,9 +41523,7 @@ "defaultValue": 1.6, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Default Line Spacing", "help": "Line-height multiplier applied to diff rows.", "hasChildren": false @@ -45062,18 +41533,11 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "view", - "image", - "file", - "both" - ], + "enumValues": ["view", "image", "file", "both"], "defaultValue": "both", "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Default Output Mode", "help": "Tool default when mode is omitted. Use view for canvas/gateway viewer, file for PNG/PDF, or both.", "hasChildren": false @@ -45086,9 +41550,7 @@ "defaultValue": true, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Show Line Numbers", "help": "Show line numbers by default.", "hasChildren": false @@ -45098,16 +41560,11 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "light", - "dark" - ], + "enumValues": ["light", "dark"], "defaultValue": "dark", "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Default Theme", "help": "Initial viewer theme.", "hasChildren": false @@ -45120,9 +41577,7 @@ "defaultValue": true, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Default Word Wrap", "help": "Wrap long lines by default.", "hasChildren": false @@ -45145,9 +41600,7 @@ "defaultValue": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Remote Viewer", "help": "Allow non-loopback access to diff viewer URLs when the token path is known.", "hasChildren": false @@ -45159,9 +41612,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable Diffs", "hasChildren": false }, @@ -45172,9 +41623,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -45186,9 +41635,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -45200,9 +41647,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/discord", "help": "OpenClaw Discord channel plugin (plugin: discord)", "hasChildren": true @@ -45214,9 +41659,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/discord Config", "help": "Plugin-defined config payload for discord.", "hasChildren": false @@ -45228,9 +41671,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/discord", "hasChildren": false }, @@ -45241,9 +41682,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -45255,9 +41694,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -45269,9 +41706,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/feishu", "help": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng) (plugin: feishu)", "hasChildren": true @@ -45283,9 +41718,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/feishu Config", "help": "Plugin-defined config payload for feishu.", "hasChildren": false @@ -45297,9 +41730,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/feishu", "hasChildren": false }, @@ -45310,9 +41741,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -45324,9 +41753,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -45338,9 +41765,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/google-gemini-cli-auth", "help": "OpenClaw Gemini CLI OAuth provider plugin (plugin: google-gemini-cli-auth)", "hasChildren": true @@ -45352,9 +41777,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/google-gemini-cli-auth Config", "help": "Plugin-defined config payload for google-gemini-cli-auth.", "hasChildren": false @@ -45366,9 +41789,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/google-gemini-cli-auth", "hasChildren": false }, @@ -45379,9 +41800,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -45393,9 +41812,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -45407,9 +41824,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/googlechat", "help": "OpenClaw Google Chat channel plugin (plugin: googlechat)", "hasChildren": true @@ -45421,9 +41836,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/googlechat Config", "help": "Plugin-defined config payload for googlechat.", "hasChildren": false @@ -45435,9 +41848,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/googlechat", "hasChildren": false }, @@ -45448,9 +41859,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -45462,9 +41871,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -45476,9 +41883,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/imessage", "help": "OpenClaw iMessage channel plugin (plugin: imessage)", "hasChildren": true @@ -45490,9 +41895,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/imessage Config", "help": "Plugin-defined config payload for imessage.", "hasChildren": false @@ -45504,9 +41907,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/imessage", "hasChildren": false }, @@ -45517,9 +41918,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -45531,9 +41930,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -45545,9 +41942,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/irc", "help": "OpenClaw IRC channel plugin (plugin: irc)", "hasChildren": true @@ -45559,9 +41954,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/irc Config", "help": "Plugin-defined config payload for irc.", "hasChildren": false @@ -45573,9 +41966,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/irc", "hasChildren": false }, @@ -45586,9 +41977,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -45600,9 +41989,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -45614,9 +42001,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/line", "help": "OpenClaw LINE channel plugin (plugin: line)", "hasChildren": true @@ -45628,9 +42013,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/line Config", "help": "Plugin-defined config payload for line.", "hasChildren": false @@ -45642,9 +42025,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/line", "hasChildren": false }, @@ -45655,9 +42036,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -45669,9 +42048,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -45683,9 +42060,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "LLM Task", "help": "Generic JSON-only LLM tool for structured tasks callable from workflows. (plugin: llm-task)", "hasChildren": true @@ -45697,9 +42072,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "LLM Task Config", "help": "Plugin-defined config payload for llm-task.", "hasChildren": true @@ -45781,9 +42154,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable LLM Task", "hasChildren": false }, @@ -45794,9 +42165,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -45808,9 +42177,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -45822,9 +42189,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Lobster", "help": "Typed workflow tool with resumable approvals. (plugin: lobster)", "hasChildren": true @@ -45836,9 +42201,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Lobster Config", "help": "Plugin-defined config payload for lobster.", "hasChildren": false @@ -45850,9 +42213,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable Lobster", "hasChildren": false }, @@ -45863,9 +42224,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -45877,9 +42236,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -45891,9 +42248,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/matrix", "help": "OpenClaw Matrix channel plugin (plugin: matrix)", "hasChildren": true @@ -45905,9 +42260,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/matrix Config", "help": "Plugin-defined config payload for matrix.", "hasChildren": false @@ -45919,9 +42272,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/matrix", "hasChildren": false }, @@ -45932,9 +42283,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -45946,9 +42295,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -45960,9 +42307,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/mattermost", "help": "OpenClaw Mattermost channel plugin (plugin: mattermost)", "hasChildren": true @@ -45974,9 +42319,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/mattermost Config", "help": "Plugin-defined config payload for mattermost.", "hasChildren": false @@ -45988,9 +42331,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/mattermost", "hasChildren": false }, @@ -46001,9 +42342,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -46015,9 +42354,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -46029,9 +42366,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/memory-core", "help": "OpenClaw core memory search plugin (plugin: memory-core)", "hasChildren": true @@ -46043,9 +42378,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/memory-core Config", "help": "Plugin-defined config payload for memory-core.", "hasChildren": false @@ -46057,9 +42390,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/memory-core", "hasChildren": false }, @@ -46070,9 +42401,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -46084,9 +42413,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -46098,9 +42425,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "@openclaw/memory-lancedb", "help": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture (plugin: memory-lancedb)", "hasChildren": true @@ -46112,9 +42437,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "@openclaw/memory-lancedb Config", "help": "Plugin-defined config payload for memory-lancedb.", "hasChildren": true @@ -46126,9 +42449,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Auto-Capture", "help": "Automatically capture important information from conversations", "hasChildren": false @@ -46140,9 +42461,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Auto-Recall", "help": "Automatically inject relevant memories into context", "hasChildren": false @@ -46154,11 +42473,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced", - "performance", - "storage" - ], + "tags": ["advanced", "performance", "storage"], "label": "Capture Max Chars", "help": "Maximum message length eligible for auto-capture", "hasChildren": false @@ -46170,10 +42485,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced", - "storage" - ], + "tags": ["advanced", "storage"], "label": "Database Path", "hasChildren": false }, @@ -46194,11 +42506,7 @@ "required": true, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "security", - "storage" - ], + "tags": ["auth", "security", "storage"], "label": "OpenAI API Key", "help": "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})", "hasChildren": false @@ -46210,10 +42518,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced", - "storage" - ], + "tags": ["advanced", "storage"], "label": "Base URL", "help": "Base URL for compatible providers (e.g. http://localhost:11434/v1)", "hasChildren": false @@ -46225,10 +42530,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced", - "storage" - ], + "tags": ["advanced", "storage"], "label": "Dimensions", "help": "Vector dimensions for custom models (required for non-standard models)", "hasChildren": false @@ -46240,10 +42542,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models", - "storage" - ], + "tags": ["models", "storage"], "label": "Embedding Model", "help": "OpenAI embedding model to use", "hasChildren": false @@ -46255,9 +42554,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Enable @openclaw/memory-lancedb", "hasChildren": false }, @@ -46268,9 +42565,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -46282,9 +42577,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -46296,9 +42589,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "@openclaw/minimax-portal-auth", "help": "OpenClaw MiniMax Portal OAuth provider plugin (plugin: minimax-portal-auth)", "hasChildren": true @@ -46310,9 +42601,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "@openclaw/minimax-portal-auth Config", "help": "Plugin-defined config payload for minimax-portal-auth.", "hasChildren": false @@ -46324,9 +42613,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Enable @openclaw/minimax-portal-auth", "hasChildren": false }, @@ -46337,9 +42624,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -46351,9 +42636,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -46365,9 +42648,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/msteams", "help": "OpenClaw Microsoft Teams channel plugin (plugin: msteams)", "hasChildren": true @@ -46379,9 +42660,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/msteams Config", "help": "Plugin-defined config payload for msteams.", "hasChildren": false @@ -46393,9 +42672,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/msteams", "hasChildren": false }, @@ -46406,9 +42683,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -46420,9 +42695,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -46434,9 +42707,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/nextcloud-talk", "help": "OpenClaw Nextcloud Talk channel plugin (plugin: nextcloud-talk)", "hasChildren": true @@ -46448,9 +42719,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/nextcloud-talk Config", "help": "Plugin-defined config payload for nextcloud-talk.", "hasChildren": false @@ -46462,9 +42731,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/nextcloud-talk", "hasChildren": false }, @@ -46475,9 +42742,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -46489,9 +42754,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -46503,9 +42766,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/nostr", "help": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs (plugin: nostr)", "hasChildren": true @@ -46517,9 +42778,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/nostr Config", "help": "Plugin-defined config payload for nostr.", "hasChildren": false @@ -46531,9 +42790,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/nostr", "hasChildren": false }, @@ -46544,9 +42801,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -46558,9 +42813,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -46572,9 +42825,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/ollama-provider", "help": "OpenClaw Ollama provider plugin (plugin: ollama)", "hasChildren": true @@ -46586,9 +42837,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/ollama-provider Config", "help": "Plugin-defined config payload for ollama.", "hasChildren": false @@ -46600,9 +42849,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/ollama-provider", "hasChildren": false }, @@ -46613,9 +42860,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -46627,9 +42872,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -46641,9 +42884,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "OpenProse", "help": "OpenProse VM skill pack with a /prose slash command. (plugin: open-prose)", "hasChildren": true @@ -46655,9 +42896,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "OpenProse Config", "help": "Plugin-defined config payload for open-prose.", "hasChildren": false @@ -46669,9 +42908,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable OpenProse", "hasChildren": false }, @@ -46682,9 +42919,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -46696,9 +42931,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -46710,9 +42943,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Phone Control", "help": "Arm/disarm high-risk phone node commands (camera/screen/writes) with an optional auto-expiry. (plugin: phone-control)", "hasChildren": true @@ -46724,9 +42955,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Phone Control Config", "help": "Plugin-defined config payload for phone-control.", "hasChildren": false @@ -46738,9 +42967,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable Phone Control", "hasChildren": false }, @@ -46751,9 +42978,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -46765,9 +42990,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -46779,9 +43002,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "qwen-portal-auth", "help": "Plugin entry for qwen-portal-auth.", "hasChildren": true @@ -46793,9 +43014,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "qwen-portal-auth Config", "help": "Plugin-defined config payload for qwen-portal-auth.", "hasChildren": false @@ -46807,9 +43026,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable qwen-portal-auth", "hasChildren": false }, @@ -46820,9 +43037,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -46834,9 +43049,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -46848,9 +43061,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/sglang-provider", "help": "OpenClaw SGLang provider plugin (plugin: sglang)", "hasChildren": true @@ -46862,9 +43073,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/sglang-provider Config", "help": "Plugin-defined config payload for sglang.", "hasChildren": false @@ -46876,9 +43085,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/sglang-provider", "hasChildren": false }, @@ -46889,9 +43096,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -46903,9 +43108,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -46917,9 +43120,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/signal", "help": "OpenClaw Signal channel plugin (plugin: signal)", "hasChildren": true @@ -46931,9 +43132,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/signal Config", "help": "Plugin-defined config payload for signal.", "hasChildren": false @@ -46945,9 +43144,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/signal", "hasChildren": false }, @@ -46958,9 +43155,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -46972,9 +43167,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -46986,9 +43179,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/slack", "help": "OpenClaw Slack channel plugin (plugin: slack)", "hasChildren": true @@ -47000,9 +43191,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/slack Config", "help": "Plugin-defined config payload for slack.", "hasChildren": false @@ -47014,9 +43203,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/slack", "hasChildren": false }, @@ -47027,9 +43214,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -47041,9 +43226,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -47055,9 +43238,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/synology-chat", "help": "Synology Chat channel plugin for OpenClaw (plugin: synology-chat)", "hasChildren": true @@ -47069,9 +43250,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/synology-chat Config", "help": "Plugin-defined config payload for synology-chat.", "hasChildren": false @@ -47083,9 +43262,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/synology-chat", "hasChildren": false }, @@ -47096,9 +43273,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -47110,9 +43285,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -47124,9 +43297,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Talk Voice", "help": "Manage Talk voice selection (list/set). (plugin: talk-voice)", "hasChildren": true @@ -47138,9 +43309,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Talk Voice Config", "help": "Plugin-defined config payload for talk-voice.", "hasChildren": false @@ -47152,9 +43321,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable Talk Voice", "hasChildren": false }, @@ -47165,9 +43332,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -47179,9 +43344,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -47193,9 +43356,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/telegram", "help": "OpenClaw Telegram channel plugin (plugin: telegram)", "hasChildren": true @@ -47207,9 +43368,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/telegram Config", "help": "Plugin-defined config payload for telegram.", "hasChildren": false @@ -47221,9 +43380,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/telegram", "hasChildren": false }, @@ -47234,9 +43391,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -47248,9 +43403,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -47262,9 +43415,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Thread Ownership", "help": "Prevents multiple agents from responding in the same Slack thread. Uses HTTP calls to the slack-forwarder ownership API. (plugin: thread-ownership)", "hasChildren": true @@ -47276,9 +43427,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Thread Ownership Config", "help": "Plugin-defined config payload for thread-ownership.", "hasChildren": true @@ -47290,9 +43439,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "A/B Test Channels", "help": "Slack channel IDs where thread ownership is enforced", "hasChildren": true @@ -47314,9 +43461,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Forwarder URL", "help": "Base URL of the slack-forwarder ownership API (default: http://slack-forwarder:8750)", "hasChildren": false @@ -47328,9 +43473,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Enable Thread Ownership", "hasChildren": false }, @@ -47341,9 +43484,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -47355,9 +43496,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -47369,9 +43508,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/tlon", "help": "OpenClaw Tlon/Urbit channel plugin (plugin: tlon)", "hasChildren": true @@ -47383,9 +43520,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/tlon Config", "help": "Plugin-defined config payload for tlon.", "hasChildren": false @@ -47397,9 +43532,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/tlon", "hasChildren": false }, @@ -47410,9 +43543,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -47424,9 +43555,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -47438,9 +43567,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/twitch", "help": "OpenClaw Twitch channel plugin (plugin: twitch)", "hasChildren": true @@ -47452,9 +43579,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/twitch Config", "help": "Plugin-defined config payload for twitch.", "hasChildren": false @@ -47466,9 +43591,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/twitch", "hasChildren": false }, @@ -47479,9 +43602,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -47493,9 +43614,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -47507,9 +43626,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/vllm-provider", "help": "OpenClaw vLLM provider plugin (plugin: vllm)", "hasChildren": true @@ -47521,9 +43638,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/vllm-provider Config", "help": "Plugin-defined config payload for vllm.", "hasChildren": false @@ -47535,9 +43650,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/vllm-provider", "hasChildren": false }, @@ -47548,9 +43661,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -47562,9 +43673,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -47576,9 +43685,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/voice-call", "help": "OpenClaw voice-call plugin (plugin: voice-call)", "hasChildren": true @@ -47590,9 +43697,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/voice-call Config", "help": "Plugin-defined config payload for voice-call.", "hasChildren": true @@ -47604,9 +43709,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Inbound Allowlist", "hasChildren": true }, @@ -47637,9 +43740,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "From Number", "hasChildren": false }, @@ -47650,9 +43751,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Inbound Greeting", "hasChildren": false }, @@ -47661,17 +43760,10 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "disabled", - "allowlist", - "pairing", - "open" - ], + "enumValues": ["disabled", "allowlist", "pairing", "open"], "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Inbound Policy", "hasChildren": false }, @@ -47710,15 +43802,10 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "notify", - "conversation" - ], + "enumValues": ["notify", "conversation"], "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Default Call Mode", "hasChildren": false }, @@ -47729,9 +43816,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Notify Hangup Delay (sec)", "hasChildren": false }, @@ -47770,17 +43855,10 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "telnyx", - "twilio", - "plivo", - "mock" - ], + "enumValues": ["telnyx", "twilio", "plivo", "mock"], "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Provider", "help": "Use twilio, telnyx, or mock for dev/no-network.", "hasChildren": false @@ -47792,9 +43870,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Public Webhook URL", "hasChildren": false }, @@ -47805,9 +43881,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Response Model", "hasChildren": false }, @@ -47818,9 +43892,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Response System Prompt", "hasChildren": false }, @@ -47831,10 +43903,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced", - "performance" - ], + "tags": ["advanced", "performance"], "label": "Response Timeout (ms)", "hasChildren": false }, @@ -47865,9 +43934,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Webhook Bind", "hasChildren": false }, @@ -47878,9 +43945,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Webhook Path", "hasChildren": false }, @@ -47891,9 +43956,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Webhook Port", "hasChildren": false }, @@ -47914,9 +43977,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Skip Signature Verification", "hasChildren": false }, @@ -47937,10 +43998,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced", - "storage" - ], + "tags": ["advanced", "storage"], "label": "Call Log Store Path", "hasChildren": false }, @@ -47961,9 +44019,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable Streaming", "hasChildren": false }, @@ -48004,11 +44060,7 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "advanced", - "auth", - "security" - ], + "tags": ["advanced", "auth", "security"], "label": "OpenAI Realtime API Key", "hasChildren": false }, @@ -48039,10 +44091,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced", - "storage" - ], + "tags": ["advanced", "storage"], "label": "Media Stream Path", "hasChildren": false }, @@ -48053,10 +44102,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced", - "media" - ], + "tags": ["advanced", "media"], "label": "Realtime STT Model", "hasChildren": false }, @@ -48065,9 +44111,7 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "openai-realtime" - ], + "enumValues": ["openai-realtime"], "deprecated": false, "sensitive": false, "tags": [], @@ -48108,9 +44152,7 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "openai" - ], + "enumValues": ["openai"], "deprecated": false, "sensitive": false, "tags": [], @@ -48131,16 +44173,10 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "off", - "serve", - "funnel" - ], + "enumValues": ["off", "serve", "funnel"], "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Tailscale Mode", "hasChildren": false }, @@ -48151,10 +44187,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced", - "storage" - ], + "tags": ["advanced", "storage"], "label": "Tailscale Path", "hasChildren": false }, @@ -48175,10 +44208,7 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "security" - ], + "tags": ["auth", "security"], "label": "Telnyx API Key", "hasChildren": false }, @@ -48189,9 +44219,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Telnyx Connection ID", "hasChildren": false }, @@ -48202,9 +44230,7 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "security" - ], + "tags": ["security"], "label": "Telnyx Public Key", "hasChildren": false }, @@ -48215,9 +44241,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Default To Number", "hasChildren": false }, @@ -48246,12 +44270,7 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "off", - "always", - "inbound", - "tagged" - ], + "enumValues": ["off", "always", "inbound", "tagged"], "deprecated": false, "sensitive": false, "tags": [], @@ -48384,12 +44403,7 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "advanced", - "auth", - "media", - "security" - ], + "tags": ["advanced", "auth", "media", "security"], "label": "ElevenLabs API Key", "hasChildren": false }, @@ -48398,11 +44412,7 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "auto", - "on", - "off" - ], + "enumValues": ["auto", "on", "off"], "deprecated": false, "sensitive": false, "tags": [], @@ -48415,10 +44425,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced", - "media" - ], + "tags": ["advanced", "media"], "label": "ElevenLabs Base URL", "hasChildren": false }, @@ -48439,11 +44446,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced", - "media", - "models" - ], + "tags": ["advanced", "media", "models"], "label": "ElevenLabs Model ID", "hasChildren": false }, @@ -48464,10 +44467,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced", - "media" - ], + "tags": ["advanced", "media"], "label": "ElevenLabs Voice ID", "hasChildren": false }, @@ -48556,10 +44556,7 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "final", - "all" - ], + "enumValues": ["final", "all"], "deprecated": false, "sensitive": false, "tags": [], @@ -48672,12 +44669,7 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "advanced", - "auth", - "media", - "security" - ], + "tags": ["advanced", "auth", "media", "security"], "label": "OpenAI API Key", "hasChildren": false }, @@ -48708,11 +44700,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced", - "media", - "models" - ], + "tags": ["advanced", "media", "models"], "label": "OpenAI TTS Model", "hasChildren": false }, @@ -48733,10 +44721,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced", - "media" - ], + "tags": ["advanced", "media"], "label": "OpenAI TTS Voice", "hasChildren": false }, @@ -48755,17 +44740,10 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "openai", - "elevenlabs", - "edge" - ], + "enumValues": ["openai", "elevenlabs", "edge"], "deprecated": false, "sensitive": false, - "tags": [ - "advanced", - "media" - ], + "tags": ["advanced", "media"], "label": "TTS Provider Override", "help": "Deep-merges with messages.tts (Edge is ignored for calls).", "hasChildren": false @@ -48807,10 +44785,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "advanced" - ], + "tags": ["access", "advanced"], "label": "Allow ngrok Free Tier (Loopback Bypass)", "hasChildren": false }, @@ -48821,11 +44796,7 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "advanced", - "auth", - "security" - ], + "tags": ["advanced", "auth", "security"], "label": "ngrok Auth Token", "hasChildren": false }, @@ -48836,9 +44807,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "ngrok Domain", "hasChildren": false }, @@ -48847,17 +44816,10 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": [ - "none", - "ngrok", - "tailscale-serve", - "tailscale-funnel" - ], + "enumValues": ["none", "ngrok", "tailscale-serve", "tailscale-funnel"], "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Tunnel Provider", "hasChildren": false }, @@ -48878,9 +44840,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Twilio Account SID", "hasChildren": false }, @@ -48891,10 +44851,7 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "security" - ], + "tags": ["auth", "security"], "label": "Twilio Auth Token", "hasChildren": false }, @@ -48965,9 +44922,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/voice-call", "hasChildren": false }, @@ -48978,9 +44933,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -48992,9 +44945,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -49006,9 +44957,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/whatsapp", "help": "OpenClaw WhatsApp channel plugin (plugin: whatsapp)", "hasChildren": true @@ -49020,9 +44969,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/whatsapp Config", "help": "Plugin-defined config payload for whatsapp.", "hasChildren": false @@ -49034,9 +44981,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/whatsapp", "hasChildren": false }, @@ -49047,9 +44992,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -49061,9 +45004,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -49075,9 +45016,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/zalo", "help": "OpenClaw Zalo channel plugin (plugin: zalo)", "hasChildren": true @@ -49089,9 +45028,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/zalo Config", "help": "Plugin-defined config payload for zalo.", "hasChildren": false @@ -49103,9 +45040,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/zalo", "hasChildren": false }, @@ -49116,9 +45051,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -49130,9 +45063,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -49144,9 +45075,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/zalouser", "help": "OpenClaw Zalo Personal Account plugin via native zca-js integration (plugin: zalouser)", "hasChildren": true @@ -49158,9 +45087,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "@openclaw/zalouser Config", "help": "Plugin-defined config payload for zalouser.", "hasChildren": false @@ -49172,9 +45099,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Enable @openclaw/zalouser", "hasChildren": false }, @@ -49185,9 +45110,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -49199,9 +45122,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access" - ], + "tags": ["access"], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -49213,9 +45134,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Install Records", "help": "CLI-managed install metadata (used by `openclaw plugins update` to locate install sources).", "hasChildren": true @@ -49237,9 +45156,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Install Time", "help": "ISO timestamp of last install/update.", "hasChildren": false @@ -49251,9 +45168,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Plugin Install Path", "help": "Resolved install directory (usually ~/.openclaw/extensions/).", "hasChildren": false @@ -49265,9 +45180,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Resolved Integrity", "help": "Resolved npm dist integrity hash for the fetched artifact (if reported by npm).", "hasChildren": false @@ -49279,9 +45192,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Resolution Time", "help": "ISO timestamp when npm package metadata was last resolved for this install record.", "hasChildren": false @@ -49293,9 +45204,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Resolved Package Name", "help": "Resolved npm package name from the fetched artifact.", "hasChildren": false @@ -49307,9 +45216,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Resolved Package Spec", "help": "Resolved exact npm spec (@) from the fetched artifact.", "hasChildren": false @@ -49321,9 +45228,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Resolved Package Version", "help": "Resolved npm package version from the fetched artifact (useful for non-pinned specs).", "hasChildren": false @@ -49335,9 +45240,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Resolved Shasum", "help": "Resolved npm dist shasum for the fetched artifact (if reported by npm).", "hasChildren": false @@ -49349,9 +45252,7 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Install Source", "help": "Install source (\"npm\", \"archive\", or \"path\").", "hasChildren": false @@ -49363,9 +45264,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Plugin Install Source Path", "help": "Original archive/path used for install (if any).", "hasChildren": false @@ -49377,9 +45276,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Install Spec", "help": "Original npm spec used for install (if source is npm).", "hasChildren": false @@ -49391,9 +45288,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Install Version", "help": "Version recorded at install time (if available).", "hasChildren": false @@ -49405,9 +45300,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Loader", "help": "Plugin loader configuration group for specifying filesystem paths where plugins are discovered. Keep load paths explicit and reviewed to avoid accidental untrusted extension loading.", "hasChildren": true @@ -49419,9 +45312,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Plugin Load Paths", "help": "Additional plugin files or directories scanned by the loader beyond built-in defaults. Use dedicated extension directories and avoid broad paths with unrelated executable content.", "hasChildren": true @@ -49443,9 +45334,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Plugin Slots", "help": "Selects which plugins own exclusive runtime slots such as memory so only one plugin provides that capability. Use explicit slot ownership to avoid overlapping providers with conflicting behavior.", "hasChildren": true @@ -49457,9 +45346,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Context Engine Plugin", "help": "Selects the active context engine plugin by id so one plugin provides context orchestration behavior.", "hasChildren": false @@ -49471,9 +45358,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Memory Plugin", "help": "Select the active memory plugin by id, or \"none\" to disable memory plugins.", "hasChildren": false @@ -49805,9 +45690,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session", "help": "Global session routing, reset, delivery policy, and maintenance controls for conversation history behavior. Keep defaults unless you need stricter isolation, retention, or delivery constraints.", "hasChildren": true @@ -49819,9 +45702,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Agent-to-Agent", "help": "Groups controls for inter-agent session exchanges, including loop prevention limits on reply chaining. Keep defaults unless you run advanced agent-to-agent automation with strict turn caps.", "hasChildren": true @@ -49833,10 +45714,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage" - ], + "tags": ["performance", "storage"], "label": "Agent-to-Agent Ping-Pong Turns", "help": "Max reply-back turns between requester and target agents during agent-to-agent exchanges (0-5). Use lower values to hard-limit chatter loops and preserve predictable run completion.", "hasChildren": false @@ -49848,9 +45726,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "DM Session Scope", "help": "DM session scoping: \"main\" keeps continuity, while \"per-peer\", \"per-channel-peer\", and \"per-account-channel-peer\" increase isolation. Use isolated modes for shared inboxes or multi-account deployments.", "hasChildren": false @@ -49862,9 +45738,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Identity Links", "help": "Maps canonical identities to provider-prefixed peer IDs so equivalent users resolve to one DM thread (example: telegram:123456). Use this when the same human appears across multiple channels or accounts.", "hasChildren": true @@ -49896,9 +45770,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Idle Minutes", "help": "Applies a legacy idle reset window in minutes for session reuse behavior across inactivity gaps. Use this only for compatibility and prefer structured reset policies under session.reset/session.resetByType.", "hasChildren": false @@ -49910,9 +45782,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Main Key", "help": "Overrides the canonical main session key used for continuity when dmScope or routing logic points to \"main\". Use a stable value only if you intentionally need custom session anchoring.", "hasChildren": false @@ -49924,9 +45794,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Maintenance", "help": "Automatic session-store maintenance controls for pruning age, entry caps, and file rotation behavior. Start in warn mode to observe impact, then enforce once thresholds are tuned.", "hasChildren": true @@ -49934,16 +45802,11 @@ { "path": "session.maintenance.highWaterBytes", "kind": "core", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Disk High-water Target", "help": "Target size after disk-budget cleanup (high-water mark). Defaults to 80% of maxDiskBytes; set explicitly for tighter reclaim behavior on constrained disks.", "hasChildren": false @@ -49951,17 +45814,11 @@ { "path": "session.maintenance.maxDiskBytes", "kind": "core", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage" - ], + "tags": ["performance", "storage"], "label": "Session Max Disk Budget", "help": "Optional per-agent sessions-directory disk budget (for example `500mb`). Use this to cap session storage per agent; when exceeded, warn mode reports pressure and enforce mode performs oldest-first cleanup.", "hasChildren": false @@ -49973,10 +45830,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage" - ], + "tags": ["performance", "storage"], "label": "Session Max Entries", "help": "Caps total session entry count retained in the store to prevent unbounded growth over time. Use lower limits for constrained environments, or higher limits when longer history is required.", "hasChildren": false @@ -49986,15 +45840,10 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "enforce", - "warn" - ], + "enumValues": ["enforce", "warn"], "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Maintenance Mode", "help": "Determines whether maintenance policies are only reported (\"warn\") or actively applied (\"enforce\"). Keep \"warn\" during rollout and switch to \"enforce\" after validating safe thresholds.", "hasChildren": false @@ -50002,16 +45851,11 @@ { "path": "session.maintenance.pruneAfter", "kind": "core", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Prune After", "help": "Removes entries older than this duration (for example `30d` or `12h`) during maintenance passes. Use this as the primary age-retention control and align it with data retention policy.", "hasChildren": false @@ -50023,9 +45867,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Prune Days (Deprecated)", "help": "Deprecated age-retention field kept for compatibility with legacy configs using day counts. Use session.maintenance.pruneAfter instead so duration syntax and behavior are consistent.", "hasChildren": false @@ -50033,17 +45875,11 @@ { "path": "session.maintenance.resetArchiveRetention", "kind": "core", - "type": [ - "boolean", - "number", - "string" - ], + "type": ["boolean", "number", "string"], "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Reset Archive Retention", "help": "Retention for reset transcript archives (`*.reset.`). Accepts a duration (for example `30d`), or `false` to disable cleanup. Defaults to pruneAfter so reset artifacts do not grow forever.", "hasChildren": false @@ -50051,16 +45887,11 @@ { "path": "session.maintenance.rotateBytes", "kind": "core", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Rotate Size", "help": "Rotates the session store when file size exceeds a threshold such as `10mb` or `1gb`. Use this to bound single-file growth and keep backup/restore operations manageable.", "hasChildren": false @@ -50072,12 +45903,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "auth", - "performance", - "security", - "storage" - ], + "tags": ["auth", "performance", "security", "storage"], "label": "Session Parent Fork Max Tokens", "help": "Maximum parent-session token count allowed for thread/session inheritance forking. If the parent exceeds this, OpenClaw starts a fresh thread session instead of forking; set 0 to disable this protection.", "hasChildren": false @@ -50089,9 +45915,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Reset Policy", "help": "Defines the default reset policy object used when no type-specific or channel-specific override applies. Set this first, then layer resetByType or resetByChannel only where behavior must differ.", "hasChildren": true @@ -50103,9 +45927,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Daily Reset Hour", "help": "Sets local-hour boundary (0-23) for daily reset mode so sessions roll over at predictable times. Use with mode=daily and align to operator timezone expectations for human-readable behavior.", "hasChildren": false @@ -50117,9 +45939,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Reset Idle Minutes", "help": "Sets inactivity window before reset for idle mode and can also act as secondary guard with daily mode. Use larger values to preserve continuity or smaller values for fresher short-lived threads.", "hasChildren": false @@ -50131,9 +45951,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Reset Mode", "help": "Selects reset strategy: \"daily\" resets at a configured hour and \"idle\" resets after inactivity windows. Keep one clear mode per policy to avoid surprising context turnover patterns.", "hasChildren": false @@ -50145,9 +45963,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Reset by Channel", "help": "Provides channel-specific reset overrides keyed by provider/channel id for fine-grained behavior control. Use this only when one channel needs exceptional reset behavior beyond type-level policies.", "hasChildren": true @@ -50199,9 +46015,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Reset by Chat Type", "help": "Overrides reset behavior by chat type (direct, group, thread) when defaults are not sufficient. Use this when group/thread traffic needs different reset cadence than direct messages.", "hasChildren": true @@ -50213,9 +46027,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Reset (Direct)", "help": "Defines reset policy for direct chats and supersedes the base session.reset configuration for that type. Use this as the canonical direct-message override instead of the legacy dm alias.", "hasChildren": true @@ -50257,9 +46069,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Reset (DM Deprecated Alias)", "help": "Deprecated alias for direct reset behavior kept for backward compatibility with older configs. Use session.resetByType.direct instead so future tooling and validation remain consistent.", "hasChildren": true @@ -50301,9 +46111,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Reset (Group)", "help": "Defines reset policy for group chat sessions where continuity and noise patterns differ from DMs. Use shorter idle windows for busy groups if context drift becomes a problem.", "hasChildren": true @@ -50345,9 +46153,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Reset (Thread)", "help": "Defines reset policy for thread-scoped sessions, including focused channel thread workflows. Use this when thread sessions should expire faster or slower than other chat types.", "hasChildren": true @@ -50389,9 +46195,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Reset Triggers", "help": "Lists message triggers that force a session reset when matched in inbound content. Use sparingly for explicit reset phrases so context is not dropped unexpectedly during normal conversation.", "hasChildren": true @@ -50413,9 +46217,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Scope", "help": "Sets base session grouping strategy: \"per-sender\" isolates by sender and \"global\" shares one session per channel context. Keep \"per-sender\" for safer multi-user behavior unless deliberate shared context is required.", "hasChildren": false @@ -50427,10 +46229,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "storage" - ], + "tags": ["access", "storage"], "label": "Session Send Policy", "help": "Controls cross-session send permissions using allow/deny rules evaluated against channel, chatType, and key prefixes. Use this to fence where session tools can deliver messages in complex environments.", "hasChildren": true @@ -50442,10 +46241,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "storage" - ], + "tags": ["access", "storage"], "label": "Session Send Policy Default Action", "help": "Sets fallback action when no sendPolicy rule matches: \"allow\" or \"deny\". Keep \"allow\" for simpler setups, or choose \"deny\" when you require explicit allow rules for every destination.", "hasChildren": false @@ -50457,10 +46253,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "storage" - ], + "tags": ["access", "storage"], "label": "Session Send Policy Rules", "help": "Ordered allow/deny rules evaluated before the default action, for example `{ action: \"deny\", match: { channel: \"discord\" } }`. Put most specific rules first so broad rules do not shadow exceptions.", "hasChildren": true @@ -50482,10 +46275,7 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "storage" - ], + "tags": ["access", "storage"], "label": "Session Send Rule Action", "help": "Defines rule decision as \"allow\" or \"deny\" when the corresponding match criteria are satisfied. Use deny-first ordering when enforcing strict boundaries with explicit allow exceptions.", "hasChildren": false @@ -50497,10 +46287,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "storage" - ], + "tags": ["access", "storage"], "label": "Session Send Rule Match", "help": "Defines optional rule match conditions that can combine channel, chatType, and key-prefix constraints. Keep matches narrow so policy intent stays readable and debugging remains straightforward.", "hasChildren": true @@ -50512,10 +46299,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "storage" - ], + "tags": ["access", "storage"], "label": "Session Send Rule Channel", "help": "Matches rule application to a specific channel/provider id (for example discord, telegram, slack). Use this when one channel should permit or deny delivery independently of others.", "hasChildren": false @@ -50527,10 +46311,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "storage" - ], + "tags": ["access", "storage"], "label": "Session Send Rule Chat Type", "help": "Matches rule application to chat type (direct, group, thread) so behavior varies by conversation form. Use this when DM and group destinations require different safety boundaries.", "hasChildren": false @@ -50542,10 +46323,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "storage" - ], + "tags": ["access", "storage"], "label": "Session Send Rule Key Prefix", "help": "Matches a normalized session-key prefix after internal key normalization steps in policy consumers. Use this for general prefix controls, and prefer rawKeyPrefix when exact full-key matching is required.", "hasChildren": false @@ -50557,10 +46335,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "storage" - ], + "tags": ["access", "storage"], "label": "Session Send Rule Raw Key Prefix", "help": "Matches the raw, unnormalized session-key prefix for exact full-key policy targeting. Use this when normalized keyPrefix is too broad and you need agent-prefixed or transport-specific precision.", "hasChildren": false @@ -50572,9 +46347,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Store Path", "help": "Sets the session storage file path used to persist session records across restarts. Use an explicit path only when you need custom disk layout, backup routing, or mounted-volume storage.", "hasChildren": false @@ -50586,9 +46359,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Thread Bindings", "help": "Shared defaults for thread-bound session routing behavior across providers that support thread focus workflows. Configure global defaults here and override per channel only when behavior differs.", "hasChildren": true @@ -50600,9 +46371,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Thread Binding Enabled", "help": "Global master switch for thread-bound session routing features and focused thread delivery behavior. Keep enabled for modern thread workflows unless you need to disable thread binding globally.", "hasChildren": false @@ -50614,9 +46383,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Thread Binding Idle Timeout (hours)", "help": "Default inactivity window in hours for thread-bound sessions across providers/channels (0 disables idle auto-unfocus). Default: 24.", "hasChildren": false @@ -50628,10 +46395,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage" - ], + "tags": ["performance", "storage"], "label": "Thread Binding Max Age (hours)", "help": "Optional hard max age in hours for thread-bound sessions across providers/channels (0 disables hard cap). Default: 0.", "hasChildren": false @@ -50643,10 +46407,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage" - ], + "tags": ["performance", "storage"], "label": "Session Typing Interval (seconds)", "help": "Controls interval for repeated typing indicators while replies are being prepared in typing-capable channels. Increase to reduce chatty updates or decrease for more active typing feedback.", "hasChildren": false @@ -50658,9 +46419,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage" - ], + "tags": ["storage"], "label": "Session Typing Mode", "help": "Controls typing behavior timing: \"never\", \"instant\", \"thinking\", or \"message\" based emission points. Keep conservative modes in high-volume channels to avoid unnecessary typing noise.", "hasChildren": false @@ -50672,9 +46431,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Skills", "hasChildren": true }, @@ -50721,17 +46478,11 @@ { "path": "skills.entries.*.apiKey", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "security" - ], + "tags": ["auth", "security"], "hasChildren": true }, { @@ -50940,9 +46691,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Watch Skills", "help": "Enable filesystem watching for skill-definition changes so updates can be applied without full process restart. Keep enabled in development workflows and disable in immutable production images.", "hasChildren": false @@ -50954,10 +46703,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation", - "performance" - ], + "tags": ["automation", "performance"], "label": "Skills Watch Debounce (ms)", "help": "Debounce window in milliseconds for coalescing rapid skill file changes before reload logic runs. Increase to reduce reload churn on frequent writes, or lower for faster edit feedback.", "hasChildren": false @@ -50969,9 +46715,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Talk", "help": "Talk-mode voice synthesis settings for voice identity, model selection, output format, and interruption behavior. Use this section to tune human-facing voice UX while controlling latency and cost.", "hasChildren": true @@ -50979,18 +46723,11 @@ { "path": "talk.apiKey", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "media", - "security" - ], + "tags": ["auth", "media", "security"], "label": "Talk API Key", "help": "Use this legacy ElevenLabs API key for Talk mode only during migration, and keep secrets in env-backed storage. Prefer talk.providers.elevenlabs.apiKey (fallback: ELEVENLABS_API_KEY).", "hasChildren": true @@ -51032,9 +46769,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media" - ], + "tags": ["media"], "label": "Talk Interrupt on Speech", "help": "If true (default), stop assistant speech when the user starts speaking in Talk mode. Keep enabled for conversational turn-taking.", "hasChildren": false @@ -51046,10 +46781,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "models" - ], + "tags": ["media", "models"], "label": "Talk Model ID", "help": "Legacy ElevenLabs model ID for Talk mode (default: eleven_v3). Prefer talk.providers.elevenlabs.modelId.", "hasChildren": false @@ -51061,9 +46793,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media" - ], + "tags": ["media"], "label": "Talk Output Format", "help": "Use this legacy ElevenLabs output format for Talk mode (for example pcm_44100 or mp3_44100_128) only during migration. Prefer talk.providers.elevenlabs.outputFormat.", "hasChildren": false @@ -51075,9 +46805,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media" - ], + "tags": ["media"], "label": "Talk Active Provider", "help": "Active Talk provider id (for example \"elevenlabs\").", "hasChildren": false @@ -51089,9 +46817,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media" - ], + "tags": ["media"], "label": "Talk Provider Settings", "help": "Provider-specific Talk settings keyed by provider id. During migration, prefer this over legacy talk.* keys.", "hasChildren": true @@ -51118,18 +46844,11 @@ { "path": "talk.providers.*.apiKey", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "media", - "security" - ], + "tags": ["auth", "media", "security"], "label": "Talk Provider API Key", "help": "Provider API key for Talk mode.", "hasChildren": true @@ -51171,10 +46890,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "models" - ], + "tags": ["media", "models"], "label": "Talk Provider Model ID", "help": "Provider default model ID for Talk mode.", "hasChildren": false @@ -51186,9 +46902,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media" - ], + "tags": ["media"], "label": "Talk Provider Output Format", "help": "Provider default output format for Talk mode.", "hasChildren": false @@ -51200,9 +46914,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media" - ], + "tags": ["media"], "label": "Talk Provider Voice Aliases", "help": "Optional provider voice alias map for Talk directives.", "hasChildren": true @@ -51224,9 +46936,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media" - ], + "tags": ["media"], "label": "Talk Provider Voice ID", "help": "Provider default voice ID for Talk mode.", "hasChildren": false @@ -51238,10 +46948,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "performance" - ], + "tags": ["media", "performance"], "label": "Talk Silence Timeout (ms)", "help": "Milliseconds of user silence before Talk mode finalizes and sends the current transcript. Leave unset to keep the platform default pause window (700 ms on macOS and Android, 900 ms on iOS).", "hasChildren": false @@ -51253,9 +46960,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media" - ], + "tags": ["media"], "label": "Talk Voice Aliases", "help": "Use this legacy ElevenLabs voice alias map (for example {\"Clawd\":\"EXAVITQu4vr4xnSDxMaL\"}) only during migration. Prefer talk.providers.elevenlabs.voiceAliases.", "hasChildren": true @@ -51277,9 +46982,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media" - ], + "tags": ["media"], "label": "Talk Voice ID", "help": "Legacy ElevenLabs default voice ID for Talk mode. Prefer talk.providers.elevenlabs.voiceId.", "hasChildren": false @@ -51291,9 +46994,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Tools", "help": "Global tool access policy and capability configuration across web, exec, media, messaging, and elevated surfaces. Use this section to constrain risky capabilities before broad rollout.", "hasChildren": true @@ -51305,9 +47006,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Agent-to-Agent Tool Access", "help": "Policy for allowing agent-to-agent tool calls and constraining which target agents can be reached. Keep disabled or tightly scoped unless cross-agent orchestration is intentionally enabled.", "hasChildren": true @@ -51319,10 +47018,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "tools" - ], + "tags": ["access", "tools"], "label": "Agent-to-Agent Target Allowlist", "help": "Allowlist of target agent IDs permitted for agent_to_agent calls when orchestration is enabled. Use explicit allowlists to avoid uncontrolled cross-agent call graphs.", "hasChildren": true @@ -51344,9 +47040,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Enable Agent-to-Agent Tool", "help": "Enables the agent_to_agent tool surface so one agent can invoke another agent at runtime. Keep off in simple deployments and enable only when orchestration value outweighs complexity.", "hasChildren": false @@ -51358,10 +47052,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "tools" - ], + "tags": ["access", "tools"], "label": "Tool Allowlist", "help": "Absolute tool allowlist that replaces profile-derived defaults for strict environments. Use this only when you intentionally run a tightly curated subset of tool capabilities.", "hasChildren": true @@ -51383,10 +47074,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "tools" - ], + "tags": ["access", "tools"], "label": "Tool Allowlist Additions", "help": "Extra tool allowlist entries merged on top of the selected tool profile and default policy. Keep this list small and explicit so audits can quickly identify intentional policy exceptions.", "hasChildren": true @@ -51408,9 +47096,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Tool Policy by Provider", "help": "Per-provider tool allow/deny overrides keyed by channel/provider ID to tailor capabilities by surface. Use this when one provider needs stricter controls than global tool policy.", "hasChildren": true @@ -51502,10 +47188,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "tools" - ], + "tags": ["access", "tools"], "label": "Tool Denylist", "help": "Global tool denylist that blocks listed tools even when profile or provider rules would allow them. Use deny rules for emergency lockouts and long-term defense-in-depth.", "hasChildren": true @@ -51527,9 +47210,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Elevated Tool Access", "help": "Elevated tool access controls for privileged command surfaces that should only be reachable from trusted senders. Keep disabled unless operator workflows explicitly require elevated actions.", "hasChildren": true @@ -51541,10 +47222,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "tools" - ], + "tags": ["access", "tools"], "label": "Elevated Tool Allow Rules", "help": "Sender allow rules for elevated tools, usually keyed by channel/provider identity formats. Use narrow, explicit identities so elevated commands cannot be triggered by unintended users.", "hasChildren": true @@ -51562,10 +47240,7 @@ { "path": "tools.elevated.allowFrom.*.*", "kind": "core", - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -51579,9 +47254,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Enable Elevated Tool Access", "help": "Enables elevated tool execution path when sender and policy checks pass. Keep disabled in public/shared channels and enable only for trusted owner-operated contexts.", "hasChildren": false @@ -51593,9 +47266,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Exec Tool", "help": "Exec-tool policy grouping for shell execution host, security mode, approval behavior, and runtime bindings. Keep conservative defaults in production and tighten elevated execution paths.", "hasChildren": true @@ -51617,10 +47288,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "tools" - ], + "tags": ["access", "tools"], "label": "apply_patch Model Allowlist", "help": "Optional allowlist of model ids (e.g. \"gpt-5.2\" or \"openai/gpt-5.2\").", "hasChildren": true @@ -51642,9 +47310,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Enable apply_patch", "help": "Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.", "hasChildren": false @@ -51656,12 +47322,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "advanced", - "security", - "tools" - ], + "tags": ["access", "advanced", "security", "tools"], "label": "apply_patch Workspace-Only", "help": "Restrict apply_patch paths to the workspace directory (default: true). Set false to allow writing outside the workspace (dangerous).", "hasChildren": false @@ -51671,16 +47332,10 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "off", - "on-miss", - "always" - ], + "enumValues": ["off", "on-miss", "always"], "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Exec Ask", "help": "Approval strategy for when exec commands require human confirmation before running. Use stricter ask behavior in shared channels and lower-friction settings in private operator contexts.", "hasChildren": false @@ -51710,16 +47365,10 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "sandbox", - "gateway", - "node" - ], + "enumValues": ["sandbox", "gateway", "node"], "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Exec Host", "help": "Selects execution host strategy for shell commands, typically controlling local vs delegated execution environment. Use the safest host mode that still satisfies your automation requirements.", "hasChildren": false @@ -51731,9 +47380,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Exec Node Binding", "help": "Node binding configuration for exec tooling when command execution is delegated through connected nodes. Use explicit node binding only when multi-node routing is required.", "hasChildren": false @@ -51745,9 +47392,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Exec Notify On Exit", "help": "When true (default), backgrounded exec sessions on exit and node exec lifecycle events enqueue a system event and request a heartbeat.", "hasChildren": false @@ -51759,9 +47404,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Exec Notify On Empty Success", "help": "When true, successful backgrounded exec exits with empty output still enqueue a completion system event (default: false).", "hasChildren": false @@ -51773,10 +47416,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage", - "tools" - ], + "tags": ["storage", "tools"], "label": "Exec PATH Prepend", "help": "Directories to prepend to PATH for exec runs (gateway/sandbox).", "hasChildren": true @@ -51798,10 +47438,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage", - "tools" - ], + "tags": ["storage", "tools"], "label": "Exec Safe Bin Profiles", "help": "Optional per-binary safe-bin profiles (positional limits + allowed/denied flags).", "hasChildren": true @@ -51883,9 +47520,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Exec Safe Bins", "help": "Allow stdin-only safe binaries to run without explicit allowlist entries.", "hasChildren": true @@ -51907,10 +47542,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage", - "tools" - ], + "tags": ["storage", "tools"], "label": "Exec Safe Bin Trusted Dirs", "help": "Additional explicit directories trusted for safe-bin path checks (PATH entries are never auto-trusted).", "hasChildren": true @@ -51930,16 +47562,10 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "deny", - "allowlist", - "full" - ], + "enumValues": ["deny", "allowlist", "full"], "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Exec Security", "help": "Execution security posture selector controlling sandbox/approval expectations for command execution. Keep strict security mode for untrusted prompts and relax only for trusted operator workflows.", "hasChildren": false @@ -51971,9 +47597,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Workspace-only FS tools", "help": "Restrict filesystem tools (read/write/edit/apply_patch) to the workspace directory (default: false).", "hasChildren": false @@ -51995,9 +47619,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Enable Link Understanding", "help": "Enable automatic link understanding pre-processing so URLs can be summarized before agent reasoning. Keep enabled for richer context, and disable when strict minimal processing is required.", "hasChildren": false @@ -52009,10 +47631,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "tools" - ], + "tags": ["performance", "tools"], "label": "Link Understanding Max Links", "help": "Maximum number of links expanded per turn during link understanding. Use lower values to control latency/cost in chatty threads and higher values when multi-link context is critical.", "hasChildren": false @@ -52024,10 +47643,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models", - "tools" - ], + "tags": ["models", "tools"], "label": "Link Understanding Models", "help": "Preferred model list for link understanding tasks, evaluated in order as fallbacks when supported. Use lightweight models first for routine summarization and heavier models only when needed.", "hasChildren": true @@ -52099,9 +47715,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Link Understanding Scope", "help": "Controls when link understanding runs relative to conversation context and message type. Keep scope conservative to avoid unnecessary fetches on messages where links are not actionable.", "hasChildren": true @@ -52203,10 +47817,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "tools" - ], + "tags": ["performance", "tools"], "label": "Link Understanding Timeout (sec)", "help": "Per-link understanding timeout budget in seconds before unresolved links are skipped. Keep this bounded to avoid long stalls when external sites are slow or unreachable.", "hasChildren": false @@ -52228,9 +47839,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Tool-loop Critical Threshold", "help": "Critical threshold for repetitive patterns when detector is enabled (default: 20).", "hasChildren": false @@ -52252,9 +47861,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Tool-loop Generic Repeat Detection", "help": "Enable generic repeated same-tool/same-params loop detection (default: true).", "hasChildren": false @@ -52266,9 +47873,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Tool-loop Poll No-Progress Detection", "help": "Enable known poll tool no-progress loop detection (default: true).", "hasChildren": false @@ -52280,9 +47885,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Tool-loop Ping-Pong Detection", "help": "Enable ping-pong loop detection (default: true).", "hasChildren": false @@ -52294,9 +47897,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Tool-loop Detection", "help": "Enable repetitive tool-call loop detection and backoff safety checks (default: false).", "hasChildren": false @@ -52308,10 +47909,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "reliability", - "tools" - ], + "tags": ["reliability", "tools"], "label": "Tool-loop Global Circuit Breaker Threshold", "help": "Global no-progress breaker threshold (default: 30).", "hasChildren": false @@ -52323,9 +47921,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Tool-loop History Size", "help": "Tool history window size for loop detection (default: 30).", "hasChildren": false @@ -52337,9 +47933,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Tool-loop Warning Threshold", "help": "Warning threshold for repetitive patterns when detector is enabled (default: 10).", "hasChildren": false @@ -52371,10 +47965,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "tools" - ], + "tags": ["media", "tools"], "label": "Audio Understanding Attachment Policy", "help": "Attachment policy for audio inputs indicating which uploaded files are eligible for audio processing. Keep restrictive defaults in mixed-content channels to avoid unintended audio workloads.", "hasChildren": true @@ -52466,10 +48057,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "tools" - ], + "tags": ["media", "tools"], "label": "Transcript Echo Format", "help": "Format string for the echoed transcript message. Use `{transcript}` as a placeholder for the transcribed text. Default: '📝 \"{transcript}\"'.", "hasChildren": false @@ -52481,10 +48069,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "tools" - ], + "tags": ["media", "tools"], "label": "Echo Transcript to Chat", "help": "Echo the audio transcript back to the originating chat before agent processing. When enabled, users immediately see what was heard from their voice note, helping them verify transcription accuracy before the agent acts on it. Default: false.", "hasChildren": false @@ -52496,10 +48081,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "tools" - ], + "tags": ["media", "tools"], "label": "Enable Audio Understanding", "help": "Enable audio understanding so voice notes or audio clips can be transcribed/summarized for agent context. Disable when audio ingestion is outside policy or unnecessary for your workflows.", "hasChildren": false @@ -52531,10 +48113,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "tools" - ], + "tags": ["media", "tools"], "label": "Audio Understanding Language", "help": "Preferred language hint for audio understanding/transcription when provider support is available. Set this to improve recognition accuracy for known primary languages.", "hasChildren": false @@ -52546,11 +48125,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "performance", - "tools" - ], + "tags": ["media", "performance", "tools"], "label": "Audio Understanding Max Bytes", "help": "Maximum accepted audio payload size in bytes before processing is rejected or clipped by policy. Set this based on expected recording length and upstream provider limits.", "hasChildren": false @@ -52562,11 +48137,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "performance", - "tools" - ], + "tags": ["media", "performance", "tools"], "label": "Audio Understanding Max Chars", "help": "Maximum characters retained from audio understanding output to prevent oversized transcript injection. Increase for long-form dictation, or lower to keep conversational turns compact.", "hasChildren": false @@ -52578,11 +48149,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "models", - "tools" - ], + "tags": ["media", "models", "tools"], "label": "Audio Understanding Models", "help": "Ordered model preferences specifically for audio understanding, used before shared media model fallback. Choose models optimized for transcription quality in your primary language/domain.", "hasChildren": true @@ -52820,11 +48387,7 @@ { "path": "tools.media.audio.models.*.providerOptions.*.*", "kind": "core", - "type": [ - "boolean", - "number", - "string" - ], + "type": ["boolean", "number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -52858,10 +48421,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "tools" - ], + "tags": ["media", "tools"], "label": "Audio Understanding Prompt", "help": "Instruction template guiding audio understanding output style, such as concise summary versus near-verbatim transcript. Keep wording consistent so downstream automations can rely on output format.", "hasChildren": false @@ -52889,11 +48449,7 @@ { "path": "tools.media.audio.providerOptions.*.*", "kind": "core", - "type": [ - "boolean", - "number", - "string" - ], + "type": ["boolean", "number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -52907,10 +48463,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "tools" - ], + "tags": ["media", "tools"], "label": "Audio Understanding Scope", "help": "Scope selector for when audio understanding runs across inbound messages and attachments. Keep focused scopes in high-volume channels to reduce cost and avoid accidental transcription.", "hasChildren": true @@ -53012,11 +48565,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "performance", - "tools" - ], + "tags": ["media", "performance", "tools"], "label": "Audio Understanding Timeout (sec)", "help": "Timeout in seconds for audio understanding execution before the operation is cancelled. Use longer timeouts for long recordings and tighter ones for interactive chat responsiveness.", "hasChildren": false @@ -53028,11 +48577,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "performance", - "tools" - ], + "tags": ["media", "performance", "tools"], "label": "Media Understanding Concurrency", "help": "Maximum number of concurrent media understanding operations per turn across image, audio, and video tasks. Lower this in resource-constrained deployments to prevent CPU/network saturation.", "hasChildren": false @@ -53054,10 +48599,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "tools" - ], + "tags": ["media", "tools"], "label": "Image Understanding Attachment Policy", "help": "Attachment handling policy for image inputs, including which message attachments qualify for image analysis. Use restrictive settings in untrusted channels to reduce unexpected processing.", "hasChildren": true @@ -53169,10 +48711,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "tools" - ], + "tags": ["media", "tools"], "label": "Enable Image Understanding", "help": "Enable image understanding so attached or referenced images can be interpreted into textual context. Disable if you need text-only operation or want to avoid image-processing cost.", "hasChildren": false @@ -53214,11 +48753,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "performance", - "tools" - ], + "tags": ["media", "performance", "tools"], "label": "Image Understanding Max Bytes", "help": "Maximum accepted image payload size in bytes before the item is skipped or truncated by policy. Keep limits realistic for your provider caps and infrastructure bandwidth.", "hasChildren": false @@ -53230,11 +48765,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "performance", - "tools" - ], + "tags": ["media", "performance", "tools"], "label": "Image Understanding Max Chars", "help": "Maximum characters returned from image understanding output after model response normalization. Use tighter limits to reduce prompt bloat and larger limits for detail-heavy OCR tasks.", "hasChildren": false @@ -53246,11 +48777,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "models", - "tools" - ], + "tags": ["media", "models", "tools"], "label": "Image Understanding Models", "help": "Ordered model preferences specifically for image understanding when you want to override shared media models. Put the most reliable multimodal model first to reduce fallback attempts.", "hasChildren": true @@ -53488,11 +49015,7 @@ { "path": "tools.media.image.models.*.providerOptions.*.*", "kind": "core", - "type": [ - "boolean", - "number", - "string" - ], + "type": ["boolean", "number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -53526,10 +49049,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "tools" - ], + "tags": ["media", "tools"], "label": "Image Understanding Prompt", "help": "Instruction template used for image understanding requests to shape extraction style and detail level. Keep prompts deterministic so outputs stay consistent across turns and channels.", "hasChildren": false @@ -53557,11 +49077,7 @@ { "path": "tools.media.image.providerOptions.*.*", "kind": "core", - "type": [ - "boolean", - "number", - "string" - ], + "type": ["boolean", "number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -53575,10 +49091,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "tools" - ], + "tags": ["media", "tools"], "label": "Image Understanding Scope", "help": "Scope selector for when image understanding is attempted (for example only explicit requests versus broader auto-detection). Keep narrow scope in busy channels to control token and API spend.", "hasChildren": true @@ -53680,11 +49193,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "performance", - "tools" - ], + "tags": ["media", "performance", "tools"], "label": "Image Understanding Timeout (sec)", "help": "Timeout in seconds for each image understanding request before it is aborted. Increase for high-resolution analysis and lower it for latency-sensitive operator workflows.", "hasChildren": false @@ -53696,11 +49205,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "models", - "tools" - ], + "tags": ["media", "models", "tools"], "label": "Media Understanding Shared Models", "help": "Shared fallback model list used by media understanding tools when modality-specific model lists are not set. Keep this aligned with available multimodal providers to avoid runtime fallback churn.", "hasChildren": true @@ -53938,11 +49443,7 @@ { "path": "tools.media.models.*.providerOptions.*.*", "kind": "core", - "type": [ - "boolean", - "number", - "string" - ], + "type": ["boolean", "number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -53986,10 +49487,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "tools" - ], + "tags": ["media", "tools"], "label": "Video Understanding Attachment Policy", "help": "Attachment eligibility policy for video analysis, defining which message files can trigger video processing. Keep this explicit in shared channels to prevent accidental large media workloads.", "hasChildren": true @@ -54101,10 +49599,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "tools" - ], + "tags": ["media", "tools"], "label": "Enable Video Understanding", "help": "Enable video understanding so clips can be summarized into text for downstream reasoning and responses. Disable when processing video is out of policy or too expensive for your deployment.", "hasChildren": false @@ -54146,11 +49641,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "performance", - "tools" - ], + "tags": ["media", "performance", "tools"], "label": "Video Understanding Max Bytes", "help": "Maximum accepted video payload size in bytes before policy rejection or trimming occurs. Tune this to provider and infrastructure limits to avoid repeated timeout/failure loops.", "hasChildren": false @@ -54162,11 +49653,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "performance", - "tools" - ], + "tags": ["media", "performance", "tools"], "label": "Video Understanding Max Chars", "help": "Maximum characters retained from video understanding output to control prompt growth. Raise for dense scene descriptions and lower when concise summaries are preferred.", "hasChildren": false @@ -54178,11 +49665,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "models", - "tools" - ], + "tags": ["media", "models", "tools"], "label": "Video Understanding Models", "help": "Ordered model preferences specifically for video understanding before shared media fallback applies. Prioritize models with strong multimodal video support to minimize degraded summaries.", "hasChildren": true @@ -54420,11 +49903,7 @@ { "path": "tools.media.video.models.*.providerOptions.*.*", "kind": "core", - "type": [ - "boolean", - "number", - "string" - ], + "type": ["boolean", "number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -54458,10 +49937,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "tools" - ], + "tags": ["media", "tools"], "label": "Video Understanding Prompt", "help": "Instruction template for video understanding describing desired summary granularity and focus areas. Keep this stable so output quality remains predictable across model/provider fallbacks.", "hasChildren": false @@ -54489,11 +49965,7 @@ { "path": "tools.media.video.providerOptions.*.*", "kind": "core", - "type": [ - "boolean", - "number", - "string" - ], + "type": ["boolean", "number", "string"], "required": false, "deprecated": false, "sensitive": false, @@ -54507,10 +49979,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "tools" - ], + "tags": ["media", "tools"], "label": "Video Understanding Scope", "help": "Scope selector controlling when video understanding is attempted across incoming events. Narrow scope in noisy channels, and broaden only where video interpretation is core to workflow.", "hasChildren": true @@ -54612,11 +50081,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "media", - "performance", - "tools" - ], + "tags": ["media", "performance", "tools"], "label": "Video Understanding Timeout (sec)", "help": "Timeout in seconds for each video understanding request before cancellation. Use conservative values in interactive channels and longer values for offline or batch-heavy processing.", "hasChildren": false @@ -54638,10 +50103,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "tools" - ], + "tags": ["access", "tools"], "label": "Allow Cross-Context Messaging", "help": "Legacy override: allow cross-context sends across all providers.", "hasChildren": false @@ -54663,9 +50125,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Enable Message Broadcast", "help": "Enable broadcast action (default: true).", "hasChildren": false @@ -54687,10 +50147,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "tools" - ], + "tags": ["access", "tools"], "label": "Allow Cross-Context (Across Providers)", "help": "Allow sends across different providers (default: false).", "hasChildren": false @@ -54702,10 +50159,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "access", - "tools" - ], + "tags": ["access", "tools"], "label": "Allow Cross-Context (Same Provider)", "help": "Allow sends to other channels within the same provider (default: true).", "hasChildren": false @@ -54727,9 +50181,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Cross-Context Marker", "help": "Add a visible origin marker when sending cross-context (default: true).", "hasChildren": false @@ -54741,9 +50193,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Cross-Context Marker Prefix", "help": "Text prefix for cross-context markers (supports \"{channel}\").", "hasChildren": false @@ -54755,9 +50205,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Cross-Context Marker Suffix", "help": "Text suffix for cross-context markers (supports \"{channel}\").", "hasChildren": false @@ -54769,10 +50217,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage", - "tools" - ], + "tags": ["storage", "tools"], "label": "Tool Profile", "help": "Global tool profile name used to select a predefined tool policy baseline before applying allow/deny overrides. Use this for consistent environment posture across agents and keep profile names stable.", "hasChildren": false @@ -54784,10 +50229,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage", - "tools" - ], + "tags": ["storage", "tools"], "label": "Sandbox Tool Policy", "help": "Tool policy wrapper for sandboxed agent executions so sandbox runs can have distinct capability boundaries. Use this to enforce stronger safety in sandbox contexts.", "hasChildren": true @@ -54799,10 +50241,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "storage", - "tools" - ], + "tags": ["storage", "tools"], "label": "Sandbox Tool Allow/Deny Policy", "help": "Allow/deny tool policy applied when agents run in sandboxed execution environments. Keep policies minimal so sandbox tasks cannot escalate into unnecessary external actions.", "hasChildren": true @@ -54952,18 +50391,10 @@ "kind": "core", "type": "string", "required": false, - "enumValues": [ - "self", - "tree", - "agent", - "all" - ], + "enumValues": ["self", "tree", "agent", "all"], "deprecated": false, "sensitive": false, - "tags": [ - "storage", - "tools" - ], + "tags": ["storage", "tools"], "label": "Session Tools Visibility", "help": "Controls which sessions can be targeted by sessions_list/sessions_history/sessions_send. (\"tree\" default = current session + spawned subagent sessions; \"self\" = only current; \"agent\" = any session in the current agent id; \"all\" = any session; cross-agent still requires tools.agentToAgent).", "hasChildren": false @@ -54975,9 +50406,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Subagent Tool Policy", "help": "Tool policy wrapper for spawned subagents to restrict or expand tool availability compared to parent defaults. Use this to keep delegated agent capabilities scoped to task intent.", "hasChildren": true @@ -54989,9 +50418,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Subagent Tool Allow/Deny Policy", "help": "Allow/deny tool policy applied to spawned subagent runtimes for per-subagent hardening. Keep this narrower than parent scope when subagents run semi-autonomous workflows.", "hasChildren": true @@ -55063,9 +50490,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Web Tools", "help": "Web-tool policy grouping for search/fetch providers, limits, and fallback behavior tuning. Keep enabled settings aligned with API key availability and outbound networking policy.", "hasChildren": true @@ -55087,11 +50512,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage", - "tools" - ], + "tags": ["performance", "storage", "tools"], "label": "Web Fetch Cache TTL (min)", "help": "Cache TTL in minutes for web_fetch results.", "hasChildren": false @@ -55103,9 +50524,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Enable Web Fetch Tool", "help": "Enable the web_fetch tool (lightweight HTTP fetch).", "hasChildren": false @@ -55123,18 +50542,11 @@ { "path": "tools.web.fetch.firecrawl.apiKey", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "security", - "tools" - ], + "tags": ["auth", "security", "tools"], "label": "Firecrawl API Key", "help": "Firecrawl API key (fallback: FIRECRAWL_API_KEY env var).", "hasChildren": true @@ -55176,9 +50588,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Firecrawl Base URL", "help": "Firecrawl base URL (e.g. https://api.firecrawl.dev or custom endpoint).", "hasChildren": false @@ -55190,9 +50600,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Enable Firecrawl Fallback", "help": "Enable Firecrawl fallback for web_fetch (if configured).", "hasChildren": false @@ -55204,10 +50612,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "tools" - ], + "tags": ["performance", "tools"], "label": "Firecrawl Cache Max Age (ms)", "help": "Firecrawl maxAge (ms) for cached results when supported by the API.", "hasChildren": false @@ -55219,9 +50624,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Firecrawl Main Content Only", "help": "When true, Firecrawl returns only the main content (default: true).", "hasChildren": false @@ -55233,10 +50636,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "tools" - ], + "tags": ["performance", "tools"], "label": "Firecrawl Timeout (sec)", "help": "Timeout in seconds for Firecrawl requests.", "hasChildren": false @@ -55248,10 +50648,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "tools" - ], + "tags": ["performance", "tools"], "label": "Web Fetch Max Chars", "help": "Max characters returned by web_fetch (truncated).", "hasChildren": false @@ -55263,10 +50660,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "tools" - ], + "tags": ["performance", "tools"], "label": "Web Fetch Hard Max Chars", "help": "Hard cap for web_fetch maxChars (applies to config and tool calls).", "hasChildren": false @@ -55278,11 +50672,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage", - "tools" - ], + "tags": ["performance", "storage", "tools"], "label": "Web Fetch Max Redirects", "help": "Maximum redirects allowed for web_fetch (default: 3).", "hasChildren": false @@ -55294,9 +50684,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Web Fetch Readability Extraction", "help": "Use Readability to extract main content from HTML (fallbacks to basic HTML cleanup).", "hasChildren": false @@ -55308,10 +50696,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "tools" - ], + "tags": ["performance", "tools"], "label": "Web Fetch Timeout (sec)", "help": "Timeout in seconds for web_fetch requests.", "hasChildren": false @@ -55323,9 +50708,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Web Fetch User-Agent", "help": "Override User-Agent header for web_fetch requests.", "hasChildren": false @@ -55343,18 +50726,11 @@ { "path": "tools.web.search.apiKey", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "security", - "tools" - ], + "tags": ["auth", "security", "tools"], "label": "Brave Search API Key", "help": "Brave Search API key (fallback: BRAVE_API_KEY env var).", "hasChildren": true @@ -55406,9 +50782,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Brave Search Mode", "help": "Brave Search mode: \"web\" (URL results) or \"llm-context\" (pre-extracted page content for LLM grounding).", "hasChildren": false @@ -55420,11 +50794,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "storage", - "tools" - ], + "tags": ["performance", "storage", "tools"], "label": "Web Search Cache TTL (min)", "help": "Cache TTL in minutes for web_search results.", "hasChildren": false @@ -55436,9 +50806,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Enable Web Search Tool", "help": "Enable the web_search tool (requires a provider API key).", "hasChildren": false @@ -55456,18 +50824,11 @@ { "path": "tools.web.search.gemini.apiKey", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "security", - "tools" - ], + "tags": ["auth", "security", "tools"], "label": "Gemini Search API Key", "help": "Gemini API key for Google Search grounding (fallback: GEMINI_API_KEY env var).", "hasChildren": true @@ -55509,10 +50870,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models", - "tools" - ], + "tags": ["models", "tools"], "label": "Gemini Search Model", "help": "Gemini model override (default: \"gemini-2.5-flash\").", "hasChildren": false @@ -55530,18 +50888,11 @@ { "path": "tools.web.search.grok.apiKey", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "security", - "tools" - ], + "tags": ["auth", "security", "tools"], "label": "Grok Search API Key", "help": "Grok (xAI) API key (fallback: XAI_API_KEY env var).", "hasChildren": true @@ -55593,10 +50944,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models", - "tools" - ], + "tags": ["models", "tools"], "label": "Grok Search Model", "help": "Grok model override (default: \"grok-4-1-fast\").", "hasChildren": false @@ -55614,18 +50962,11 @@ { "path": "tools.web.search.kimi.apiKey", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "security", - "tools" - ], + "tags": ["auth", "security", "tools"], "label": "Kimi Search API Key", "help": "Moonshot/Kimi API key (fallback: KIMI_API_KEY or MOONSHOT_API_KEY env var).", "hasChildren": true @@ -55667,9 +51008,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Kimi Search Base URL", "help": "Kimi base URL override (default: \"https://api.moonshot.ai/v1\").", "hasChildren": false @@ -55681,10 +51020,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models", - "tools" - ], + "tags": ["models", "tools"], "label": "Kimi Search Model", "help": "Kimi model override (default: \"moonshot-v1-128k\").", "hasChildren": false @@ -55696,10 +51032,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "tools" - ], + "tags": ["performance", "tools"], "label": "Web Search Max Results", "help": "Number of results to return (1-10).", "hasChildren": false @@ -55717,18 +51050,11 @@ { "path": "tools.web.search.perplexity.apiKey", "kind": "core", - "type": [ - "object", - "string" - ], + "type": ["object", "string"], "required": false, "deprecated": false, "sensitive": true, - "tags": [ - "auth", - "security", - "tools" - ], + "tags": ["auth", "security", "tools"], "label": "Perplexity API Key", "help": "Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var). Direct Perplexity keys default to the Search API; OpenRouter keys use Sonar chat completions.", "hasChildren": true @@ -55770,9 +51096,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Perplexity Base URL", "help": "Optional Perplexity/OpenRouter chat-completions base URL override. Setting this opts Perplexity into the legacy Sonar/OpenRouter compatibility path.", "hasChildren": false @@ -55784,10 +51108,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "models", - "tools" - ], + "tags": ["models", "tools"], "label": "Perplexity Model", "help": "Optional Sonar/OpenRouter model override (default: \"perplexity/sonar-pro\"). Setting this opts Perplexity into the legacy chat-completions compatibility path.", "hasChildren": false @@ -55799,9 +51120,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "tools" - ], + "tags": ["tools"], "label": "Web Search Provider", "help": "Search provider (\"brave\", \"gemini\", \"grok\", \"kimi\", or \"perplexity\"). Auto-detected from available API keys if omitted.", "hasChildren": false @@ -55813,10 +51132,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance", - "tools" - ], + "tags": ["performance", "tools"], "label": "Web Search Timeout (sec)", "help": "Timeout in seconds for web_search requests.", "hasChildren": false @@ -55828,9 +51144,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "UI", "help": "UI presentation settings for accenting and assistant identity shown in control surfaces. Use this for branding and readability customization without changing runtime behavior.", "hasChildren": true @@ -55842,9 +51156,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Assistant Appearance", "help": "Assistant display identity settings for name and avatar shown in UI surfaces. Keep these values aligned with your operator-facing persona and support expectations.", "hasChildren": true @@ -55856,9 +51168,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Assistant Avatar", "help": "Assistant avatar image source used in UI surfaces (URL, path, or data URI depending on runtime support). Use trusted assets and consistent branding dimensions for clean rendering.", "hasChildren": false @@ -55870,9 +51180,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Assistant Name", "help": "Display name shown for the assistant in UI views, chat chrome, and status contexts. Keep this stable so operators can reliably identify which assistant persona is active.", "hasChildren": false @@ -55884,9 +51192,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Accent Color", "help": "Primary accent/seam color used by UI surfaces for emphasis, badges, and visual identity cues. Use high-contrast values that remain readable across light/dark themes.", "hasChildren": false @@ -55898,9 +51204,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Updates", "help": "Update-channel and startup-check behavior for keeping OpenClaw runtime versions current. Use conservative channels in production and more experimental channels only in controlled environments.", "hasChildren": true @@ -55922,9 +51226,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Auto Update Beta Check Interval (hours)", "help": "How often beta-channel checks run in hours (default: 1).", "hasChildren": false @@ -55936,9 +51238,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Auto Update Enabled", "help": "Enable background auto-update for package installs (default: false).", "hasChildren": false @@ -55950,9 +51250,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Auto Update Stable Delay (hours)", "help": "Minimum delay before stable-channel auto-apply starts (default: 6).", "hasChildren": false @@ -55964,9 +51262,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Auto Update Stable Jitter (hours)", "help": "Extra stable-channel rollout spread window in hours (default: 12).", "hasChildren": false @@ -55978,9 +51274,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Update Channel", "help": "Update channel for git + npm installs (\"stable\", \"beta\", or \"dev\").", "hasChildren": false @@ -55992,9 +51286,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation" - ], + "tags": ["automation"], "label": "Update Check on Start", "help": "Check for npm updates when the gateway starts (default: true).", "hasChildren": false @@ -56006,9 +51298,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Web Channel", "help": "Web channel runtime settings for heartbeat and reconnect behavior when operating web-based chat surfaces. Use reconnect values tuned to your network reliability profile and expected uptime needs.", "hasChildren": true @@ -56020,9 +51310,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Web Channel Enabled", "help": "Enables the web channel runtime and related websocket lifecycle behavior. Keep disabled when web chat is unused to reduce active connection management overhead.", "hasChildren": false @@ -56034,9 +51322,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "automation" - ], + "tags": ["automation"], "label": "Web Channel Heartbeat Interval (sec)", "help": "Heartbeat interval in seconds for web channel connectivity and liveness maintenance. Use shorter intervals for faster detection, or longer intervals to reduce keepalive chatter.", "hasChildren": false @@ -56048,9 +51334,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Web Channel Reconnect Policy", "help": "Reconnect backoff policy for web channel reconnect attempts after transport failure. Keep bounded retries and jitter tuned to avoid thundering-herd reconnect behavior.", "hasChildren": true @@ -56062,9 +51346,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Web Reconnect Backoff Factor", "help": "Exponential backoff multiplier used between reconnect attempts in web channel retry loops. Keep factor above 1 and tune with jitter for stable large-fleet reconnect behavior.", "hasChildren": false @@ -56076,9 +51358,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Web Reconnect Initial Delay (ms)", "help": "Initial reconnect delay in milliseconds before the first retry after disconnection. Use modest delays to recover quickly without immediate retry storms.", "hasChildren": false @@ -56090,9 +51370,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Web Reconnect Jitter", "help": "Randomization factor (0-1) applied to reconnect delays to desynchronize clients after outage events. Keep non-zero jitter in multi-client deployments to reduce synchronized spikes.", "hasChildren": false @@ -56104,9 +51382,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Web Reconnect Max Attempts", "help": "Maximum reconnect attempts before giving up for the current failure sequence (0 means no retries). Use finite caps for controlled failure handling in automation-sensitive environments.", "hasChildren": false @@ -56118,9 +51394,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "performance" - ], + "tags": ["performance"], "label": "Web Reconnect Max Delay (ms)", "help": "Maximum reconnect backoff cap in milliseconds to bound retry delay growth over repeated failures. Use a reasonable cap so recovery remains timely after prolonged outages.", "hasChildren": false @@ -56132,9 +51406,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Setup Wizard State", "help": "Setup wizard state tracking fields that record the most recent guided onboarding run details. Keep these fields for observability and troubleshooting of setup flows across upgrades.", "hasChildren": true @@ -56146,9 +51418,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Wizard Last Run Timestamp", "help": "ISO timestamp for when the setup wizard most recently completed on this host. Use this to confirm onboarding recency during support and operational audits.", "hasChildren": false @@ -56160,9 +51430,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Wizard Last Run Command", "help": "Command invocation recorded for the latest wizard run to preserve execution context. Use this to reproduce onboarding steps when verifying setup regressions.", "hasChildren": false @@ -56174,9 +51442,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Wizard Last Run Commit", "help": "Source commit identifier recorded for the last wizard execution in development builds. Use this to correlate onboarding behavior with exact source state during debugging.", "hasChildren": false @@ -56188,9 +51454,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Wizard Last Run Mode", "help": "Wizard execution mode recorded as \"local\" or \"remote\" for the most recent onboarding flow. Use this to understand whether setup targeted direct local runtime or remote gateway topology.", "hasChildren": false @@ -56202,9 +51466,7 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": [ - "advanced" - ], + "tags": ["advanced"], "label": "Wizard Last Run Version", "help": "OpenClaw version recorded at the time of the most recent wizard run on this config. Use this when diagnosing behavior differences across version-to-version onboarding changes.", "hasChildren": false diff --git a/src/gateway/server.device-pair-approve-authz.test.ts b/src/gateway/server.device-pair-approve-authz.test.ts index 20c1d6d5959..9ed5ce0950d 100644 --- a/src/gateway/server.device-pair-approve-authz.test.ts +++ b/src/gateway/server.device-pair-approve-authz.test.ts @@ -63,11 +63,11 @@ async function issuePairingScopedOperator(name: string): Promise<{ role: "operator", scopes: ["operator.pairing"], }); - expect(rotated?.token).toBeTruthy(); + expect(rotated.ok ? rotated.entry.token : "").toBeTruthy(); return { identityPath: loaded.identityPath, deviceId: loaded.identity.deviceId, - token: String(rotated?.token ?? ""), + token: rotated.ok ? rotated.entry.token : "", }; } diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index b452e951bc8..e6cf9259a66 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -85,6 +85,11 @@ export type ApproveDevicePairingResult = | { status: "forbidden"; missingScope: string } | null; +type ApprovedDevicePairingResult = Extract< + NonNullable, + { status: "approved" } +>; + type DevicePairingStateFile = { pendingById: Record; pairedByDeviceId: Record; @@ -343,6 +348,15 @@ export async function requestDevicePairing( }); } +export async function approveDevicePairing( + requestId: string, + baseDir?: string, +): Promise; +export async function approveDevicePairing( + requestId: string, + options: { callerScopes?: readonly string[] }, + baseDir?: string, +): Promise; export async function approveDevicePairing( requestId: string, optionsOrBaseDir?: { callerScopes?: readonly string[] } | string, From dd2eb290384535f99af91f83816ac2b70e2cc575 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 12:11:55 -0700 Subject: [PATCH 010/943] Commands: split static onboard auth choice help (#47545) * Commands: split static onboard auth choice help * Tests: cover static onboard auth choice help * Changelog: note static onboard auth choice help --- CHANGELOG.md | 1 + src/cli/program/register.onboard.test.ts | 4 +- src/cli/program/register.onboard.ts | 4 +- src/commands/auth-choice-options.static.ts | 332 +++++++++++++++++++++ src/commands/auth-choice-options.test.ts | 21 ++ src/commands/auth-choice-options.ts | 331 +------------------- 6 files changed, 366 insertions(+), 327 deletions(-) create mode 100644 src/commands/auth-choice-options.static.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 65bee8da1aa..052510b8628 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,7 @@ Docs: https://docs.openclaw.ai - macOS/exec approvals: respect per-agent exec approval settings in the gateway prompter, including allowlist fallback when the native prompt cannot be shown, so gateway-triggered `system.run` requests follow configured policy instead of always prompting or denying unexpectedly. (#13707) Thanks @sliekens. - Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus. - Telegram/inbound media IPv4 fallback: retry SSRF-guarded Telegram file downloads once with the same IPv4 fallback policy as Bot API calls so fresh installs on IPv6-broken hosts no longer fail to download inbound images. +- Commands/onboarding: split static auth-choice help from the plugin-backed onboarding catalog so `openclaw onboard` registration no longer pulls provider-wizard imports just to describe `--auth-choice`. (#47545) Thanks @vincentkoc. - Windows/gateway install: bound `schtasks` calls and fall back to the Startup-folder login item when task creation hangs, so native `openclaw gateway install` fails fast instead of wedging forever on broken Scheduled Task setups. - Windows/gateway stop: resolve Startup-folder fallback listeners from the installed `gateway.cmd` port, so `openclaw gateway stop` now actually kills fallback-launched gateway processes before restart. - Windows/gateway status: reuse the installed service command environment when reading runtime status, so startup-fallback gateways keep reporting the configured port and running state in `gateway status --json` instead of falling back to `gateway port unknown`. diff --git a/src/cli/program/register.onboard.test.ts b/src/cli/program/register.onboard.test.ts index 53bc1dbc7a5..086296c8895 100644 --- a/src/cli/program/register.onboard.test.ts +++ b/src/cli/program/register.onboard.test.ts @@ -9,8 +9,8 @@ const runtime = { exit: vi.fn(), }; -vi.mock("../../commands/auth-choice-options.js", () => ({ - formatAuthChoiceChoicesForCli: () => "token|oauth", +vi.mock("../../commands/auth-choice-options.static.js", () => ({ + formatStaticAuthChoiceChoicesForCli: () => "token|oauth", })); vi.mock("../../commands/onboard-provider-auth-flags.js", () => ({ diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 4dd285e63c1..8c742f0ab66 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { formatAuthChoiceChoicesForCli } from "../../commands/auth-choice-options.js"; +import { formatStaticAuthChoiceChoicesForCli } from "../../commands/auth-choice-options.static.js"; import type { GatewayDaemonRuntime } from "../../commands/daemon-runtime.js"; import { ONBOARD_PROVIDER_AUTH_FLAGS } from "../../commands/onboard-provider-auth-flags.js"; import type { @@ -41,7 +41,7 @@ function resolveInstallDaemonFlag( return undefined; } -const AUTH_CHOICE_HELP = formatAuthChoiceChoicesForCli({ +const AUTH_CHOICE_HELP = formatStaticAuthChoiceChoicesForCli({ includeLegacyAliases: true, includeSkip: true, }); diff --git a/src/commands/auth-choice-options.static.ts b/src/commands/auth-choice-options.static.ts new file mode 100644 index 00000000000..f42c208333f --- /dev/null +++ b/src/commands/auth-choice-options.static.ts @@ -0,0 +1,332 @@ +import { AUTH_CHOICE_LEGACY_ALIASES_FOR_CLI } from "./auth-choice-legacy.js"; +import { ONBOARD_PROVIDER_AUTH_FLAGS } from "./onboard-provider-auth-flags.js"; +import type { AuthChoice, AuthChoiceGroupId } from "./onboard-types.js"; + +export type { AuthChoiceGroupId }; + +export type AuthChoiceOption = { + value: AuthChoice; + label: string; + hint?: string; +}; +export type AuthChoiceGroup = { + value: AuthChoiceGroupId; + label: string; + hint?: string; + options: AuthChoiceOption[]; +}; + +export const AUTH_CHOICE_GROUP_DEFS: { + value: AuthChoiceGroupId; + label: string; + hint?: string; + choices: AuthChoice[]; +}[] = [ + { + value: "openai", + label: "OpenAI", + hint: "Codex OAuth + API key", + choices: ["openai-codex", "openai-api-key"], + }, + { + value: "anthropic", + label: "Anthropic", + hint: "setup-token + API key", + choices: ["token", "apiKey"], + }, + { + value: "chutes", + label: "Chutes", + hint: "OAuth", + choices: ["chutes"], + }, + { + value: "minimax", + label: "MiniMax", + hint: "M2.5 (recommended)", + choices: ["minimax-global-oauth", "minimax-global-api", "minimax-cn-oauth", "minimax-cn-api"], + }, + { + value: "moonshot", + label: "Moonshot AI (Kimi K2.5)", + hint: "Kimi K2.5 + Kimi Coding", + choices: ["moonshot-api-key", "moonshot-api-key-cn", "kimi-code-api-key"], + }, + { + value: "google", + label: "Google", + hint: "Gemini API key + OAuth", + choices: ["gemini-api-key", "google-gemini-cli"], + }, + { + value: "xai", + label: "xAI (Grok)", + hint: "API key", + choices: ["xai-api-key"], + }, + { + value: "mistral", + label: "Mistral AI", + hint: "API key", + choices: ["mistral-api-key"], + }, + { + value: "volcengine", + label: "Volcano Engine", + hint: "API key", + choices: ["volcengine-api-key"], + }, + { + value: "byteplus", + label: "BytePlus", + hint: "API key", + choices: ["byteplus-api-key"], + }, + { + value: "openrouter", + label: "OpenRouter", + hint: "API key", + choices: ["openrouter-api-key"], + }, + { + value: "kilocode", + label: "Kilo Gateway", + hint: "API key (OpenRouter-compatible)", + choices: ["kilocode-api-key"], + }, + { + value: "qwen", + label: "Qwen", + hint: "OAuth", + choices: ["qwen-portal"], + }, + { + value: "zai", + label: "Z.AI", + hint: "GLM Coding Plan / Global / CN", + choices: ["zai-coding-global", "zai-coding-cn", "zai-global", "zai-cn"], + }, + { + value: "qianfan", + label: "Qianfan", + hint: "API key", + choices: ["qianfan-api-key"], + }, + { + value: "modelstudio", + label: "Alibaba Cloud Model Studio", + hint: "Coding Plan API key (CN / Global)", + choices: ["modelstudio-api-key-cn", "modelstudio-api-key"], + }, + { + value: "copilot", + label: "Copilot", + hint: "GitHub + local proxy", + choices: ["github-copilot", "copilot-proxy"], + }, + { + value: "ai-gateway", + label: "Vercel AI Gateway", + hint: "API key", + choices: ["ai-gateway-api-key"], + }, + { + value: "opencode", + label: "OpenCode", + hint: "Shared API key for Zen + Go catalogs", + choices: ["opencode-zen", "opencode-go"], + }, + { + value: "xiaomi", + label: "Xiaomi", + hint: "API key", + choices: ["xiaomi-api-key"], + }, + { + value: "synthetic", + label: "Synthetic", + hint: "Anthropic-compatible (multi-model)", + choices: ["synthetic-api-key"], + }, + { + value: "together", + label: "Together AI", + hint: "API key", + choices: ["together-api-key"], + }, + { + value: "huggingface", + label: "Hugging Face", + hint: "Inference API (HF token)", + choices: ["huggingface-api-key"], + }, + { + value: "venice", + label: "Venice AI", + hint: "Privacy-focused (uncensored models)", + choices: ["venice-api-key"], + }, + { + value: "litellm", + label: "LiteLLM", + hint: "Unified LLM gateway (100+ providers)", + choices: ["litellm-api-key"], + }, + { + value: "cloudflare-ai-gateway", + label: "Cloudflare AI Gateway", + hint: "Account ID + Gateway ID + API key", + choices: ["cloudflare-ai-gateway-api-key"], + }, + { + value: "custom", + label: "Custom Provider", + hint: "Any OpenAI or Anthropic compatible endpoint", + choices: ["custom-api-key"], + }, +]; + +const PROVIDER_AUTH_CHOICE_OPTION_HINTS: Partial> = { + "litellm-api-key": "Unified gateway for 100+ LLM providers", + "cloudflare-ai-gateway-api-key": "Account ID + Gateway ID + API key", + "venice-api-key": "Privacy-focused inference (uncensored models)", + "together-api-key": "Access to Llama, DeepSeek, Qwen, and more open models", + "huggingface-api-key": "Inference Providers — OpenAI-compatible chat", + "opencode-zen": "Shared OpenCode key; curated Zen catalog", + "opencode-go": "Shared OpenCode key; Kimi/GLM/MiniMax Go catalog", +}; + +const PROVIDER_AUTH_CHOICE_OPTION_LABELS: Partial> = { + "moonshot-api-key": "Kimi API key (.ai)", + "moonshot-api-key-cn": "Kimi API key (.cn)", + "kimi-code-api-key": "Kimi Code API key (subscription)", + "cloudflare-ai-gateway-api-key": "Cloudflare AI Gateway", + "opencode-zen": "OpenCode Zen catalog", + "opencode-go": "OpenCode Go catalog", +}; + +function buildProviderAuthChoiceOptions(): AuthChoiceOption[] { + return ONBOARD_PROVIDER_AUTH_FLAGS.map((flag) => ({ + value: flag.authChoice, + label: PROVIDER_AUTH_CHOICE_OPTION_LABELS[flag.authChoice] ?? flag.description, + ...(PROVIDER_AUTH_CHOICE_OPTION_HINTS[flag.authChoice] + ? { hint: PROVIDER_AUTH_CHOICE_OPTION_HINTS[flag.authChoice] } + : {}), + })); +} + +export const BASE_AUTH_CHOICE_OPTIONS: ReadonlyArray = [ + { + value: "token", + label: "Anthropic token (paste setup-token)", + hint: "run `claude setup-token` elsewhere, then paste the token here", + }, + { + value: "openai-codex", + label: "OpenAI Codex (ChatGPT OAuth)", + }, + { value: "chutes", label: "Chutes (OAuth)" }, + ...buildProviderAuthChoiceOptions(), + { + value: "moonshot-api-key-cn", + label: "Kimi API key (.cn)", + }, + { + value: "github-copilot", + label: "GitHub Copilot (GitHub device login)", + hint: "Uses GitHub device flow", + }, + { value: "gemini-api-key", label: "Google Gemini API key" }, + { + value: "google-gemini-cli", + label: "Google Gemini CLI OAuth", + hint: "Unofficial flow; review account-risk warning before use", + }, + { value: "zai-api-key", label: "Z.AI API key" }, + { + value: "zai-coding-global", + label: "Coding-Plan-Global", + hint: "GLM Coding Plan Global (api.z.ai)", + }, + { + value: "zai-coding-cn", + label: "Coding-Plan-CN", + hint: "GLM Coding Plan CN (open.bigmodel.cn)", + }, + { + value: "zai-global", + label: "Global", + hint: "Z.AI Global (api.z.ai)", + }, + { + value: "zai-cn", + label: "CN", + hint: "Z.AI CN (open.bigmodel.cn)", + }, + { + value: "xiaomi-api-key", + label: "Xiaomi API key", + }, + { + value: "minimax-global-oauth", + label: "MiniMax Global — OAuth (minimax.io)", + hint: "Only supports OAuth for the coding plan", + }, + { + value: "minimax-global-api", + label: "MiniMax Global — API Key (minimax.io)", + hint: "sk-api- or sk-cp- keys supported", + }, + { + value: "minimax-cn-oauth", + label: "MiniMax CN — OAuth (minimaxi.com)", + hint: "Only supports OAuth for the coding plan", + }, + { + value: "minimax-cn-api", + label: "MiniMax CN — API Key (minimaxi.com)", + hint: "sk-api- or sk-cp- keys supported", + }, + { value: "qwen-portal", label: "Qwen OAuth" }, + { + value: "copilot-proxy", + label: "Copilot Proxy (local)", + hint: "Local proxy for VS Code Copilot models", + }, + { value: "apiKey", label: "Anthropic API key" }, + { + value: "opencode-zen", + label: "OpenCode Zen catalog", + hint: "Claude, GPT, Gemini via opencode.ai/zen", + }, + { value: "qianfan-api-key", label: "Qianfan API key" }, + { + value: "modelstudio-api-key-cn", + label: "Coding Plan API Key for China (subscription)", + hint: "Endpoint: coding.dashscope.aliyuncs.com", + }, + { + value: "modelstudio-api-key", + label: "Coding Plan API Key for Global/Intl (subscription)", + hint: "Endpoint: coding-intl.dashscope.aliyuncs.com", + }, + { value: "custom-api-key", label: "Custom Provider" }, +]; + +export function formatStaticAuthChoiceChoicesForCli(params?: { + includeSkip?: boolean; + includeLegacyAliases?: boolean; +}): string { + const includeSkip = params?.includeSkip ?? true; + const includeLegacyAliases = params?.includeLegacyAliases ?? false; + const values = BASE_AUTH_CHOICE_OPTIONS.map((opt) => opt.value); + + if (includeSkip) { + values.push("skip"); + } + if (includeLegacyAliases) { + values.push(...AUTH_CHOICE_LEGACY_ALIASES_FOR_CLI); + } + + return values.join("|"); +} diff --git a/src/commands/auth-choice-options.test.ts b/src/commands/auth-choice-options.test.ts index 74b729d5db8..c45297a001e 100644 --- a/src/commands/auth-choice-options.test.ts +++ b/src/commands/auth-choice-options.test.ts @@ -6,6 +6,7 @@ import { buildAuthChoiceOptions, formatAuthChoiceChoicesForCli, } from "./auth-choice-options.js"; +import { formatStaticAuthChoiceChoicesForCli } from "./auth-choice-options.static.js"; const resolveProviderWizardOptions = vi.hoisted(() => vi.fn<() => ProviderWizardOption[]>(() => []), @@ -104,6 +105,26 @@ describe("buildAuthChoiceOptions", () => { expect(cliChoices).toContain("codex-cli"); }); + it("keeps static cli help choices off the plugin-backed catalog", () => { + resolveProviderWizardOptions.mockReturnValue([ + { + value: "ollama", + label: "Ollama", + hint: "Cloud and local open models", + groupId: "ollama", + groupLabel: "Ollama", + }, + ]); + + const cliChoices = formatStaticAuthChoiceChoicesForCli({ + includeLegacyAliases: false, + includeSkip: true, + }).split("|"); + + expect(cliChoices).not.toContain("ollama"); + expect(cliChoices).toContain("skip"); + }); + it("shows Chutes in grouped provider selection", () => { const { groups } = buildAuthChoiceGroups({ store: EMPTY_STORE, diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 95bb74d1c14..3e97a103aad 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -1,321 +1,15 @@ import type { AuthProfileStore } from "../agents/auth-profiles.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveProviderWizardOptions } from "../plugins/provider-wizard.js"; -import { AUTH_CHOICE_LEGACY_ALIASES_FOR_CLI } from "./auth-choice-legacy.js"; -import { ONBOARD_PROVIDER_AUTH_FLAGS } from "./onboard-provider-auth-flags.js"; +import { + AUTH_CHOICE_GROUP_DEFS, + BASE_AUTH_CHOICE_OPTIONS, + type AuthChoiceGroup, + type AuthChoiceOption, + formatStaticAuthChoiceChoicesForCli, +} from "./auth-choice-options.static.js"; import type { AuthChoice, AuthChoiceGroupId } from "./onboard-types.js"; -export type { AuthChoiceGroupId }; - -export type AuthChoiceOption = { - value: AuthChoice; - label: string; - hint?: string; -}; -export type AuthChoiceGroup = { - value: AuthChoiceGroupId; - label: string; - hint?: string; - options: AuthChoiceOption[]; -}; - -const AUTH_CHOICE_GROUP_DEFS: { - value: AuthChoiceGroupId; - label: string; - hint?: string; - choices: AuthChoice[]; -}[] = [ - { - value: "openai", - label: "OpenAI", - hint: "Codex OAuth + API key", - choices: ["openai-codex", "openai-api-key"], - }, - { - value: "anthropic", - label: "Anthropic", - hint: "setup-token + API key", - choices: ["token", "apiKey"], - }, - { - value: "chutes", - label: "Chutes", - hint: "OAuth", - choices: ["chutes"], - }, - { - value: "minimax", - label: "MiniMax", - hint: "M2.5 (recommended)", - choices: ["minimax-global-oauth", "minimax-global-api", "minimax-cn-oauth", "minimax-cn-api"], - }, - { - value: "moonshot", - label: "Moonshot AI (Kimi K2.5)", - hint: "Kimi K2.5 + Kimi Coding", - choices: ["moonshot-api-key", "moonshot-api-key-cn", "kimi-code-api-key"], - }, - { - value: "google", - label: "Google", - hint: "Gemini API key + OAuth", - choices: ["gemini-api-key", "google-gemini-cli"], - }, - { - value: "xai", - label: "xAI (Grok)", - hint: "API key", - choices: ["xai-api-key"], - }, - { - value: "mistral", - label: "Mistral AI", - hint: "API key", - choices: ["mistral-api-key"], - }, - { - value: "volcengine", - label: "Volcano Engine", - hint: "API key", - choices: ["volcengine-api-key"], - }, - { - value: "byteplus", - label: "BytePlus", - hint: "API key", - choices: ["byteplus-api-key"], - }, - { - value: "openrouter", - label: "OpenRouter", - hint: "API key", - choices: ["openrouter-api-key"], - }, - { - value: "kilocode", - label: "Kilo Gateway", - hint: "API key (OpenRouter-compatible)", - choices: ["kilocode-api-key"], - }, - { - value: "qwen", - label: "Qwen", - hint: "OAuth", - choices: ["qwen-portal"], - }, - { - value: "zai", - label: "Z.AI", - hint: "GLM Coding Plan / Global / CN", - choices: ["zai-coding-global", "zai-coding-cn", "zai-global", "zai-cn"], - }, - { - value: "qianfan", - label: "Qianfan", - hint: "API key", - choices: ["qianfan-api-key"], - }, - { - value: "modelstudio", - label: "Alibaba Cloud Model Studio", - hint: "Coding Plan API key (CN / Global)", - choices: ["modelstudio-api-key-cn", "modelstudio-api-key"], - }, - { - value: "copilot", - label: "Copilot", - hint: "GitHub + local proxy", - choices: ["github-copilot", "copilot-proxy"], - }, - { - value: "ai-gateway", - label: "Vercel AI Gateway", - hint: "API key", - choices: ["ai-gateway-api-key"], - }, - { - value: "opencode", - label: "OpenCode", - hint: "Shared API key for Zen + Go catalogs", - choices: ["opencode-zen", "opencode-go"], - }, - { - value: "xiaomi", - label: "Xiaomi", - hint: "API key", - choices: ["xiaomi-api-key"], - }, - { - value: "synthetic", - label: "Synthetic", - hint: "Anthropic-compatible (multi-model)", - choices: ["synthetic-api-key"], - }, - { - value: "together", - label: "Together AI", - hint: "API key", - choices: ["together-api-key"], - }, - { - value: "huggingface", - label: "Hugging Face", - hint: "Inference API (HF token)", - choices: ["huggingface-api-key"], - }, - { - value: "venice", - label: "Venice AI", - hint: "Privacy-focused (uncensored models)", - choices: ["venice-api-key"], - }, - { - value: "litellm", - label: "LiteLLM", - hint: "Unified LLM gateway (100+ providers)", - choices: ["litellm-api-key"], - }, - { - value: "cloudflare-ai-gateway", - label: "Cloudflare AI Gateway", - hint: "Account ID + Gateway ID + API key", - choices: ["cloudflare-ai-gateway-api-key"], - }, - { - value: "custom", - label: "Custom Provider", - hint: "Any OpenAI or Anthropic compatible endpoint", - choices: ["custom-api-key"], - }, -]; - -const PROVIDER_AUTH_CHOICE_OPTION_HINTS: Partial> = { - "litellm-api-key": "Unified gateway for 100+ LLM providers", - "cloudflare-ai-gateway-api-key": "Account ID + Gateway ID + API key", - "venice-api-key": "Privacy-focused inference (uncensored models)", - "together-api-key": "Access to Llama, DeepSeek, Qwen, and more open models", - "huggingface-api-key": "Inference Providers — OpenAI-compatible chat", - "opencode-zen": "Shared OpenCode key; curated Zen catalog", - "opencode-go": "Shared OpenCode key; Kimi/GLM/MiniMax Go catalog", -}; - -const PROVIDER_AUTH_CHOICE_OPTION_LABELS: Partial> = { - "moonshot-api-key": "Kimi API key (.ai)", - "moonshot-api-key-cn": "Kimi API key (.cn)", - "kimi-code-api-key": "Kimi Code API key (subscription)", - "cloudflare-ai-gateway-api-key": "Cloudflare AI Gateway", - "opencode-zen": "OpenCode Zen catalog", - "opencode-go": "OpenCode Go catalog", -}; - -function buildProviderAuthChoiceOptions(): AuthChoiceOption[] { - return ONBOARD_PROVIDER_AUTH_FLAGS.map((flag) => ({ - value: flag.authChoice, - label: PROVIDER_AUTH_CHOICE_OPTION_LABELS[flag.authChoice] ?? flag.description, - ...(PROVIDER_AUTH_CHOICE_OPTION_HINTS[flag.authChoice] - ? { hint: PROVIDER_AUTH_CHOICE_OPTION_HINTS[flag.authChoice] } - : {}), - })); -} - -const BASE_AUTH_CHOICE_OPTIONS: ReadonlyArray = [ - { - value: "token", - label: "Anthropic token (paste setup-token)", - hint: "run `claude setup-token` elsewhere, then paste the token here", - }, - { - value: "openai-codex", - label: "OpenAI Codex (ChatGPT OAuth)", - }, - { value: "chutes", label: "Chutes (OAuth)" }, - ...buildProviderAuthChoiceOptions(), - { - value: "moonshot-api-key-cn", - label: "Kimi API key (.cn)", - }, - { - value: "github-copilot", - label: "GitHub Copilot (GitHub device login)", - hint: "Uses GitHub device flow", - }, - { value: "gemini-api-key", label: "Google Gemini API key" }, - { - value: "google-gemini-cli", - label: "Google Gemini CLI OAuth", - hint: "Unofficial flow; review account-risk warning before use", - }, - { value: "zai-api-key", label: "Z.AI API key" }, - { - value: "zai-coding-global", - label: "Coding-Plan-Global", - hint: "GLM Coding Plan Global (api.z.ai)", - }, - { - value: "zai-coding-cn", - label: "Coding-Plan-CN", - hint: "GLM Coding Plan CN (open.bigmodel.cn)", - }, - { - value: "zai-global", - label: "Global", - hint: "Z.AI Global (api.z.ai)", - }, - { - value: "zai-cn", - label: "CN", - hint: "Z.AI CN (open.bigmodel.cn)", - }, - { - value: "xiaomi-api-key", - label: "Xiaomi API key", - }, - { - value: "minimax-global-oauth", - label: "MiniMax Global — OAuth (minimax.io)", - hint: "Only supports OAuth for the coding plan", - }, - { - value: "minimax-global-api", - label: "MiniMax Global — API Key (minimax.io)", - hint: "sk-api- or sk-cp- keys supported", - }, - { - value: "minimax-cn-oauth", - label: "MiniMax CN — OAuth (minimaxi.com)", - hint: "Only supports OAuth for the coding plan", - }, - { - value: "minimax-cn-api", - label: "MiniMax CN — API Key (minimaxi.com)", - hint: "sk-api- or sk-cp- keys supported", - }, - { value: "qwen-portal", label: "Qwen OAuth" }, - { - value: "copilot-proxy", - label: "Copilot Proxy (local)", - hint: "Local proxy for VS Code Copilot models", - }, - { value: "apiKey", label: "Anthropic API key" }, - { - value: "opencode-zen", - label: "OpenCode Zen catalog", - hint: "Claude, GPT, Gemini via opencode.ai/zen", - }, - { value: "qianfan-api-key", label: "Qianfan API key" }, - { - value: "modelstudio-api-key-cn", - label: "Coding Plan API Key for China (subscription)", - hint: "Endpoint: coding.dashscope.aliyuncs.com", - }, - { - value: "modelstudio-api-key", - label: "Coding Plan API Key for Global/Intl (subscription)", - hint: "Endpoint: coding-intl.dashscope.aliyuncs.com", - }, - { value: "custom-api-key", label: "Custom Provider" }, -]; - function resolveDynamicProviderCliChoices(params?: { config?: OpenClawConfig; workspaceDir?: string; @@ -331,20 +25,11 @@ export function formatAuthChoiceChoicesForCli(params?: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }): string { - const includeSkip = params?.includeSkip ?? true; - const includeLegacyAliases = params?.includeLegacyAliases ?? false; const values = [ - ...BASE_AUTH_CHOICE_OPTIONS.map((opt) => opt.value), + ...formatStaticAuthChoiceChoicesForCli(params).split("|"), ...resolveDynamicProviderCliChoices(params), ]; - if (includeSkip) { - values.push("skip"); - } - if (includeLegacyAliases) { - values.push(...AUTH_CHOICE_LEGACY_ALIASES_FOR_CLI); - } - return values.join("|"); } From bbb0c3e5d7d9acab512d70abceafdba11d7ff490 Mon Sep 17 00:00:00 2001 From: xiaoyi Date: Mon, 16 Mar 2026 03:14:30 +0800 Subject: [PATCH 011/943] CLI/completion: fix generator OOM and harden plugin registries (#45537) * fix: avoid OOM during completion script generation * CLI/completion: fix PowerShell nested command paths * CLI/completion: cover generated shell scripts * Changelog: note completion generator follow-up * Plugins: reserve shared registry names --------- Co-authored-by: Xiaoyi Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + src/cli/completion-cli.test.ts | 52 ++++++++++++ src/cli/completion-cli.ts | 136 +++++++++++++++--------------- src/plugins/loader.test.ts | 147 +++++++++++++++++++++++++++++++++ src/plugins/registry.ts | 42 ++++++++++ 5 files changed, 307 insertions(+), 71 deletions(-) create mode 100644 src/cli/completion-cli.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 052510b8628..ebbfb3f0924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - CLI/startup: lazy-load channel add and root help startup paths to trim avoidable RSS and help latency on constrained hosts. (#46784) Thanks @vincentkoc. - CLI/onboarding: import static provider definitions directly for onboarding model/config helpers so those paths no longer pull provider discovery just for built-in defaults. (#47467) Thanks @vincentkoc. - CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc. +- CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc. ## 2026.3.13 diff --git a/src/cli/completion-cli.test.ts b/src/cli/completion-cli.test.ts new file mode 100644 index 00000000000..d2f34b0e8cb --- /dev/null +++ b/src/cli/completion-cli.test.ts @@ -0,0 +1,52 @@ +import { Command } from "commander"; +import { describe, expect, it } from "vitest"; +import { getCompletionScript } from "./completion-cli.js"; + +function createCompletionProgram(): Command { + const program = new Command(); + program.name("openclaw"); + program.description("CLI root"); + program.option("-v, --verbose", "Verbose output"); + + const gateway = program.command("gateway").description("Gateway commands"); + gateway.option("--force", "Force the action"); + + gateway.command("status").description("Show gateway status").option("--json", "JSON output"); + gateway.command("restart").description("Restart gateway"); + + return program; +} + +describe("completion-cli", () => { + it("generates zsh functions for nested subcommands", () => { + const script = getCompletionScript("zsh", createCompletionProgram()); + + expect(script).toContain("_openclaw_gateway()"); + expect(script).toContain("(status) _openclaw_gateway_status ;;"); + expect(script).toContain("(restart) _openclaw_gateway_restart ;;"); + expect(script).toContain("--force[Force the action]"); + }); + + it("generates PowerShell command paths without the executable prefix", () => { + const script = getCompletionScript("powershell", createCompletionProgram()); + + expect(script).toContain("if ($commandPath -eq 'gateway') {"); + expect(script).toContain("if ($commandPath -eq 'gateway status') {"); + expect(script).not.toContain("if ($commandPath -eq 'openclaw gateway') {"); + expect(script).toContain("$completions = @('status','restart','--force')"); + }); + + it("generates fish completions for root and nested command contexts", () => { + const script = getCompletionScript("fish", createCompletionProgram()); + + expect(script).toContain( + 'complete -c openclaw -n "__fish_use_subcommand" -a "gateway" -d \'Gateway commands\'', + ); + expect(script).toContain( + 'complete -c openclaw -n "__fish_seen_subcommand_from gateway" -a "status" -d \'Show gateway status\'', + ); + expect(script).toContain( + "complete -c openclaw -n \"__fish_seen_subcommand_from gateway\" -l force -d 'Force the action'", + ); + }); +}); diff --git a/src/cli/completion-cli.ts b/src/cli/completion-cli.ts index 01cd02c018c..cbc235e41f9 100644 --- a/src/cli/completion-cli.ts +++ b/src/cli/completion-cli.ts @@ -69,7 +69,7 @@ export async function completionCacheExists( return pathExists(cachePath); } -function getCompletionScript(shell: CompletionShell, program: Command): string { +export function getCompletionScript(shell: CompletionShell, program: Command): string { if (shell === "zsh") { return generateZshCompletion(program); } @@ -442,17 +442,19 @@ function generateZshSubcmdList(cmd: Command): string { } function generateZshSubcommands(program: Command, prefix: string): string { - let script = ""; - for (const cmd of program.commands) { - const cmdName = cmd.name(); - const funcName = `_${prefix}_${cmdName.replace(/-/g, "_")}`; + const segments: string[] = []; - // Recurse first - script += generateZshSubcommands(cmd, `${prefix}_${cmdName.replace(/-/g, "_")}`); + const visit = (current: Command, currentPrefix: string) => { + for (const cmd of current.commands) { + const cmdName = cmd.name(); + const nextPrefix = `${currentPrefix}_${cmdName.replace(/-/g, "_")}`; + const funcName = `_${nextPrefix}`; - const subCommands = cmd.commands; - if (subCommands.length > 0) { - script += ` + visit(cmd, nextPrefix); + + const subCommands = cmd.commands; + if (subCommands.length > 0) { + segments.push(` ${funcName}() { local -a commands local -a options @@ -470,17 +472,21 @@ ${funcName}() { ;; esac } -`; - } else { - script += ` +`); + continue; + } + + segments.push(` ${funcName}() { _arguments -C \\ ${generateZshArgs(cmd)} } -`; +`); } - } - return script; + }; + + visit(program, prefix); + return segments.join(""); } function generateBashCompletion(program: Command): string { @@ -528,38 +534,34 @@ function generateBashSubcommand(cmd: Command): string { function generatePowerShellCompletion(program: Command): string { const rootCmd = program.name(); + const segments: string[] = []; - const visit = (cmd: Command, parents: string[]): string => { - const cmdName = cmd.name(); - const fullPath = [...parents, cmdName].join(" "); - - let script = ""; + const visit = (cmd: Command, pathSegments: string[]) => { + const fullPath = pathSegments.join(" "); // Command completion for this level const subCommands = cmd.commands.map((c) => c.name()); const options = cmd.options.map((o) => o.flags.split(/[ ,|]+/)[0]); // Take first flag const allCompletions = [...subCommands, ...options].map((s) => `'${s}'`).join(","); - if (allCompletions.length > 0) { - script += ` + if (fullPath.length > 0 && allCompletions.length > 0) { + segments.push(` if ($commandPath -eq '${fullPath}') { $completions = @(${allCompletions}) $completions | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_) } } -`; +`); } - // Recurse for (const sub of cmd.commands) { - script += visit(sub, [...parents, cmdName]); + visit(sub, [...pathSegments, sub.name()]); } - - return script; }; - const rootBody = visit(program, []); + visit(program, []); + const rootBody = segments.join(""); return ` Register-ArgumentCompleter -Native -CommandName ${rootCmd} -ScriptBlock { @@ -593,65 +595,57 @@ Register-ArgumentCompleter -Native -CommandName ${rootCmd} -ScriptBlock { function generateFishCompletion(program: Command): string { const rootCmd = program.name(); - let script = ""; + const segments: string[] = []; const visit = (cmd: Command, parents: string[]) => { const cmdName = cmd.name(); - const fullPath = [...parents]; - if (parents.length > 0) { - fullPath.push(cmdName); - } // Only push if not root, or consistent root handling - - // Fish uses 'seen_subcommand_from' to determine context. - // For root: complete -c openclaw -n "__fish_use_subcommand" -a "subcmd" -d "desc" // Root logic if (parents.length === 0) { // Subcommands of root for (const sub of cmd.commands) { - script += buildFishSubcommandCompletionLine({ - rootCmd, - condition: "__fish_use_subcommand", - name: sub.name(), - description: sub.description(), - }); + segments.push( + buildFishSubcommandCompletionLine({ + rootCmd, + condition: "__fish_use_subcommand", + name: sub.name(), + description: sub.description(), + }), + ); } // Options of root for (const opt of cmd.options) { - script += buildFishOptionCompletionLine({ - rootCmd, - condition: "__fish_use_subcommand", - flags: opt.flags, - description: opt.description, - }); + segments.push( + buildFishOptionCompletionLine({ + rootCmd, + condition: "__fish_use_subcommand", + flags: opt.flags, + description: opt.description, + }), + ); } } else { - // Nested commands - // Logic: if seen subcommand matches parents... - // But fish completion logic is simpler if we just say "if we haven't seen THIS command yet but seen parent" - // Actually, a robust fish completion often requires defining a function to check current line. - // For simplicity, we'll assume standard fish helper __fish_seen_subcommand_from. - - // To properly scope to 'openclaw gateway' and not 'openclaw other gateway', we need to check the sequence. - // A simplified approach: - // Subcommands for (const sub of cmd.commands) { - script += buildFishSubcommandCompletionLine({ - rootCmd, - condition: `__fish_seen_subcommand_from ${cmdName}`, - name: sub.name(), - description: sub.description(), - }); + segments.push( + buildFishSubcommandCompletionLine({ + rootCmd, + condition: `__fish_seen_subcommand_from ${cmdName}`, + name: sub.name(), + description: sub.description(), + }), + ); } // Options for (const opt of cmd.options) { - script += buildFishOptionCompletionLine({ - rootCmd, - condition: `__fish_seen_subcommand_from ${cmdName}`, - flags: opt.flags, - description: opt.description, - }); + segments.push( + buildFishOptionCompletionLine({ + rootCmd, + condition: `__fish_seen_subcommand_from ${cmdName}`, + flags: opt.flags, + description: opt.description, + }), + ); } } @@ -661,5 +655,5 @@ function generateFishCompletion(program: Command): string { }; visit(program, []); - return script; + return segments.join(""); } diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index c37cfbfd46c..ac6ff410268 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -986,6 +986,153 @@ describe("loadOpenClawPlugins", () => { expect(httpPlugin?.httpRoutes).toBe(1); }); + it("rejects duplicate plugin-visible hook names", () => { + useNoBundledPlugins(); + const first = writePlugin({ + id: "hook-owner-a", + filename: "hook-owner-a.cjs", + body: `module.exports = { id: "hook-owner-a", register(api) { + api.registerHook("gateway:startup", () => {}, { name: "shared-hook" }); +} };`, + }); + const second = writePlugin({ + id: "hook-owner-b", + filename: "hook-owner-b.cjs", + body: `module.exports = { id: "hook-owner-b", register(api) { + api.registerHook("gateway:startup", () => {}, { name: "shared-hook" }); +} };`, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [first.file, second.file] }, + allow: ["hook-owner-a", "hook-owner-b"], + }, + }, + }); + + expect(registry.hooks.filter((entry) => entry.entry.hook.name === "shared-hook")).toHaveLength( + 1, + ); + expect( + registry.diagnostics.some( + (diag) => + diag.level === "error" && + diag.pluginId === "hook-owner-b" && + diag.message === "hook already registered: shared-hook (hook-owner-a)", + ), + ).toBe(true); + }); + + it("rejects duplicate plugin service ids", () => { + useNoBundledPlugins(); + const first = writePlugin({ + id: "service-owner-a", + filename: "service-owner-a.cjs", + body: `module.exports = { id: "service-owner-a", register(api) { + api.registerService({ id: "shared-service", start() {} }); +} };`, + }); + const second = writePlugin({ + id: "service-owner-b", + filename: "service-owner-b.cjs", + body: `module.exports = { id: "service-owner-b", register(api) { + api.registerService({ id: "shared-service", start() {} }); +} };`, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [first.file, second.file] }, + allow: ["service-owner-a", "service-owner-b"], + }, + }, + }); + + expect(registry.services.filter((entry) => entry.service.id === "shared-service")).toHaveLength( + 1, + ); + expect( + registry.diagnostics.some( + (diag) => + diag.level === "error" && + diag.pluginId === "service-owner-b" && + diag.message === "service already registered: shared-service (service-owner-a)", + ), + ).toBe(true); + }); + + it("requires plugin CLI registrars to declare explicit command roots", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "cli-missing-metadata", + filename: "cli-missing-metadata.cjs", + body: `module.exports = { id: "cli-missing-metadata", register(api) { + api.registerCli(() => {}); +} };`, + }); + + const registry = loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: ["cli-missing-metadata"], + }, + }); + + expect(registry.cliRegistrars).toHaveLength(0); + expect( + registry.diagnostics.some( + (diag) => + diag.level === "error" && + diag.pluginId === "cli-missing-metadata" && + diag.message === "cli registration missing explicit commands metadata", + ), + ).toBe(true); + }); + + it("rejects duplicate plugin CLI command roots", () => { + useNoBundledPlugins(); + const first = writePlugin({ + id: "cli-owner-a", + filename: "cli-owner-a.cjs", + body: `module.exports = { id: "cli-owner-a", register(api) { + api.registerCli(() => {}, { commands: ["shared-cli"] }); +} };`, + }); + const second = writePlugin({ + id: "cli-owner-b", + filename: "cli-owner-b.cjs", + body: `module.exports = { id: "cli-owner-b", register(api) { + api.registerCli(() => {}, { commands: ["shared-cli"] }); +} };`, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [first.file, second.file] }, + allow: ["cli-owner-a", "cli-owner-b"], + }, + }, + }); + + expect(registry.cliRegistrars).toHaveLength(1); + expect(registry.cliRegistrars[0]?.pluginId).toBe("cli-owner-a"); + expect( + registry.diagnostics.some( + (diag) => + diag.level === "error" && + diag.pluginId === "cli-owner-b" && + diag.message === "cli command already registered: shared-cli (cli-owner-a)", + ), + ).toBe(true); + }); + it("registers http routes", () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index ca987dc8e79..c1c63cc96cb 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -238,6 +238,16 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); return; } + const existingHook = registry.hooks.find((entry) => entry.entry.hook.name === name); + if (existingHook) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `hook already registered: ${name} (${existingHook.pluginId})`, + }); + return; + } const description = entry?.hook.description ?? opts?.description ?? ""; const hookEntry: HookEntry = entry @@ -473,6 +483,28 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { opts?: { commands?: string[] }, ) => { const commands = (opts?.commands ?? []).map((cmd) => cmd.trim()).filter(Boolean); + if (commands.length === 0) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "cli registration missing explicit commands metadata", + }); + return; + } + const existing = registry.cliRegistrars.find((entry) => + entry.commands.some((command) => commands.includes(command)), + ); + if (existing) { + const overlap = commands.find((command) => existing.commands.includes(command)); + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `cli command already registered: ${overlap ?? commands[0]} (${existing.pluginId})`, + }); + return; + } record.cliCommands.push(...commands); registry.cliRegistrars.push({ pluginId: record.id, @@ -487,6 +519,16 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { if (!id) { return; } + const existing = registry.services.find((entry) => entry.service.id === id); + if (existing) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `service already registered: ${id} (${existing.pluginId})`, + }); + return; + } record.services.push(id); registry.services.push({ pluginId: record.id, From e2dac5d5cbf6e2c395e294e7569b13afa2e758c7 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Sun, 15 Mar 2026 21:16:27 +0200 Subject: [PATCH 012/943] fix(plugins): load bundled extensions from dist (#47560) --- CHANGELOG.md | 1 + extensions/llm-task/src/llm-task-tool.test.ts | 4 +- extensions/llm-task/src/llm-task-tool.ts | 34 +---------- extensions/whatsapp/src/channel.ts | 8 +-- extensions/whatsapp/src/runtime.ts | 3 +- package.json | 5 +- scripts/copy-bundled-plugin-metadata.mjs | 57 +++++++++++++++++++ src/plugin-sdk/subpaths.test.ts | 5 ++ src/plugin-sdk/whatsapp.ts | 6 ++ src/plugins/loader.test.ts | 36 ++++++++++++ src/plugins/loader.ts | 33 +++++++++++ tsconfig.json | 1 + tsdown.config.ts | 53 +++++++++++++++++ vitest.config.ts | 4 ++ 14 files changed, 206 insertions(+), 44 deletions(-) create mode 100644 scripts/copy-bundled-plugin-metadata.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index ebbfb3f0924..fc9aa9435ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai - CLI/onboarding: import static provider definitions directly for onboarding model/config helpers so those paths no longer pull provider discovery just for built-in defaults. (#47467) Thanks @vincentkoc. - CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc. - CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc. +- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. ## 2026.3.13 diff --git a/extensions/llm-task/src/llm-task-tool.test.ts b/extensions/llm-task/src/llm-task-tool.test.ts index 2bf0cb655aa..49feb7929ff 100644 --- a/extensions/llm-task/src/llm-task-tool.test.ts +++ b/extensions/llm-task/src/llm-task-tool.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -vi.mock("../../../src/agents/pi-embedded-runner.js", () => { +vi.mock("openclaw/extension-api", () => { return { runEmbeddedPiAgent: vi.fn(async () => ({ meta: { startedAt: Date.now() }, @@ -9,7 +9,7 @@ vi.mock("../../../src/agents/pi-embedded-runner.js", () => { }; }); -import { runEmbeddedPiAgent } from "../../../src/agents/pi-embedded-runner.js"; +import { runEmbeddedPiAgent } from "openclaw/extension-api"; import { createLlmTaskTool } from "./llm-task-tool.js"; // oxlint-disable-next-line typescript/no-explicit-any diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts index ff2037e534a..d79e0a51130 100644 --- a/extensions/llm-task/src/llm-task-tool.ts +++ b/extensions/llm-task/src/llm-task-tool.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { Type } from "@sinclair/typebox"; import Ajv from "ajv"; +import { runEmbeddedPiAgent } from "openclaw/extension-api"; import { formatThinkingLevels, formatXHighModelHint, @@ -9,39 +10,8 @@ import { resolvePreferredOpenClawTmpDir, supportsXHighThinking, } from "openclaw/plugin-sdk/llm-task"; -// NOTE: This extension is intended to be bundled with OpenClaw. -// When running from source (tests/dev), OpenClaw internals live under src/. -// When running from a built install, internals live under dist/ (no src/ tree). -// So we resolve internal imports dynamically with src-first, dist-fallback. import type { OpenClawPluginApi } from "openclaw/plugin-sdk/llm-task"; -type RunEmbeddedPiAgentFn = (params: Record) => Promise; - -async function loadRunEmbeddedPiAgent(): Promise { - // Source checkout (tests/dev) - try { - const mod = await import("../../../src/agents/pi-embedded-runner.js"); - // oxlint-disable-next-line typescript/no-explicit-any - if (typeof (mod as any).runEmbeddedPiAgent === "function") { - // oxlint-disable-next-line typescript/no-explicit-any - return (mod as any).runEmbeddedPiAgent; - } - } catch { - // ignore - } - - // Bundled install (built) - // NOTE: there is no src/ tree in a packaged install. Prefer a stable internal entrypoint. - const distExtensionApi = "../../../dist/extensionAPI.js"; - const mod = (await import(distExtensionApi)) as { runEmbeddedPiAgent?: unknown }; - // oxlint-disable-next-line typescript/no-explicit-any - const fn = (mod as any).runEmbeddedPiAgent; - if (typeof fn !== "function") { - throw new Error("Internal error: runEmbeddedPiAgent not available"); - } - return fn as RunEmbeddedPiAgentFn; -} - function stripCodeFences(s: string): string { const trimmed = s.trim(); const m = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); @@ -209,8 +179,6 @@ export function createLlmTaskTool(api: OpenClawPluginApi) { const sessionId = `llm-task-${Date.now()}`; const sessionFile = path.join(tmpDir, "session.json"); - const runEmbeddedPiAgent = await loadRunEmbeddedPiAgent(); - const result = await runEmbeddedPiAgent({ sessionId, sessionFile, diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 8a60dc44432..1745f8caa74 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,11 +1,9 @@ -import { - buildAccountScopedDmSecurityPolicy, - collectAllowlistProviderGroupPolicyWarnings, - collectOpenGroupPolicyRouteAllowlistWarnings, -} from "openclaw/plugin-sdk/compat"; import { applyAccountNameToChannelSection, buildChannelConfigSchema, + buildAccountScopedDmSecurityPolicy, + collectAllowlistProviderGroupPolicyWarnings, + collectOpenGroupPolicyRouteAllowlistWarnings, createActionGate, createWhatsAppOutboundBase, DEFAULT_ACCOUNT_ID, diff --git a/extensions/whatsapp/src/runtime.ts b/extensions/whatsapp/src/runtime.ts index 13ace8243db..bf415eb17db 100644 --- a/extensions/whatsapp/src/runtime.ts +++ b/extensions/whatsapp/src/runtime.ts @@ -1,5 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; -import type { PluginRuntime } from "openclaw/plugin-sdk/whatsapp"; +import { createPluginRuntimeStore, type PluginRuntime } from "openclaw/plugin-sdk/whatsapp"; const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } = createPluginRuntimeStore("WhatsApp runtime not initialized"); diff --git a/package.json b/package.json index 053e4bea2a3..2d880e80fe7 100644 --- a/package.json +++ b/package.json @@ -212,6 +212,7 @@ "types": "./dist/plugin-sdk/keyed-async-queue.d.ts", "default": "./dist/plugin-sdk/keyed-async-queue.js" }, + "./extension-api": "./dist/extensionAPI.js", "./cli-entry": "./openclaw.mjs" }, "scripts": { @@ -224,8 +225,8 @@ "android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n ai.openclaw.app/.MainActivity", "android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest", "android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 vitest run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts", - "build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", - "build:docker": "node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", + "build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && node scripts/copy-bundled-plugin-metadata.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", + "build:docker": "node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && node scripts/copy-bundled-plugin-metadata.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json || true", "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs new file mode 100644 index 00000000000..40d8baa5299 --- /dev/null +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; + +const repoRoot = process.cwd(); +const extensionsRoot = path.join(repoRoot, "extensions"); +const distExtensionsRoot = path.join(repoRoot, "dist", "extensions"); + +function rewritePackageExtensions(entries) { + if (!Array.isArray(entries)) { + return undefined; + } + + return entries + .filter((entry) => typeof entry === "string" && entry.trim().length > 0) + .map((entry) => { + const normalized = entry.replace(/^\.\//, ""); + const rewritten = normalized.replace(/\.[^.]+$/u, ".js"); + return `./${rewritten}`; + }); +} + +for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) { + if (!dirent.isDirectory()) { + continue; + } + + const pluginDir = path.join(extensionsRoot, dirent.name); + const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); + if (!fs.existsSync(manifestPath)) { + continue; + } + + const distPluginDir = path.join(distExtensionsRoot, dirent.name); + fs.mkdirSync(distPluginDir, { recursive: true }); + fs.copyFileSync(manifestPath, path.join(distPluginDir, "openclaw.plugin.json")); + + const packageJsonPath = path.join(pluginDir, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + continue; + } + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + if (packageJson.openclaw && "extensions" in packageJson.openclaw) { + packageJson.openclaw = { + ...packageJson.openclaw, + extensions: rewritePackageExtensions(packageJson.openclaw.extensions), + }; + } + + fs.writeFileSync( + path.join(distPluginDir, "package.json"), + `${JSON.stringify(packageJson, null, 2)}\n`, + "utf8", + ); +} diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 2d971c82255..e0d4827b879 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -1,3 +1,4 @@ +import * as extensionApi from "openclaw/extension-api"; import * as compatSdk from "openclaw/plugin-sdk/compat"; import * as discordSdk from "openclaw/plugin-sdk/discord"; import * as imessageSdk from "openclaw/plugin-sdk/imessage"; @@ -132,4 +133,8 @@ describe("plugin-sdk subpath exports", () => { const zalo = await import("openclaw/plugin-sdk/zalo"); expect(typeof zalo.resolveClientIp).toBe("function"); }); + + it("exports the extension api bridge", () => { + expect(typeof extensionApi.runEmbeddedPiAgent).toBe("function"); + }); }); diff --git a/src/plugin-sdk/whatsapp.ts b/src/plugin-sdk/whatsapp.ts index f18a953bf7a..4ea4fa8d2de 100644 --- a/src/plugin-sdk/whatsapp.ts +++ b/src/plugin-sdk/whatsapp.ts @@ -25,6 +25,11 @@ export { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, } from "../channels/plugins/directory-config.js"; +export { + collectAllowlistProviderGroupPolicyWarnings, + collectOpenGroupPolicyRouteAllowlistWarnings, +} from "../channels/plugins/group-policy-warnings.js"; +export { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js"; export { resolveWhatsAppOutboundTarget } from "../whatsapp/resolve-outbound-target.js"; export { @@ -44,5 +49,6 @@ export { resolveWhatsAppHeartbeatRecipients } from "../channels/plugins/whatsapp export { WhatsAppConfigSchema } from "../config/zod-schema.providers-whatsapp.js"; export { createActionGate, readStringParam } from "../agents/tools/common.js"; +export { createPluginRuntimeStore } from "./runtime-store.js"; export { normalizeE164 } from "../utils.js"; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index ac6ff410268..e0d3a3537d0 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -284,6 +284,22 @@ function createPluginSdkAliasFixture(params?: { return { root, srcFile, distFile }; } +function createExtensionApiAliasFixture(params?: { srcBody?: string; distBody?: string }) { + const root = makeTempDir(); + const srcFile = path.join(root, "src", "extensionAPI.ts"); + const distFile = path.join(root, "dist", "extensionAPI.js"); + mkdirSafe(path.dirname(srcFile)); + mkdirSafe(path.dirname(distFile)); + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ name: "openclaw", type: "module" }, null, 2), + "utf-8", + ); + fs.writeFileSync(srcFile, params?.srcBody ?? "export {};\n", "utf-8"); + fs.writeFileSync(distFile, params?.distBody ?? "export {};\n", "utf-8"); + return { root, srcFile, distFile }; +} + afterEach(() => { clearPluginLoaderCache(); if (prevBundledDir === undefined) { @@ -2334,4 +2350,24 @@ describe("loadOpenClawPlugins", () => { ); expect(resolved).toBe(srcFile); }); + + it("prefers dist extension-api alias when loader runs from dist", () => { + const { root, distFile } = createExtensionApiAliasFixture(); + + const resolved = __testing.resolveExtensionApiAlias({ + modulePath: path.join(root, "dist", "plugins", "loader.js"), + }); + expect(resolved).toBe(distFile); + }); + + it("prefers src extension-api alias when loader runs from src in non-production", () => { + const { root, srcFile } = createExtensionApiAliasFixture(); + + const resolved = withEnv({ NODE_ENV: undefined }, () => + __testing.resolveExtensionApiAlias({ + modulePath: path.join(root, "src", "plugins", "loader.ts"), + }), + ); + expect(resolved).toBe(srcFile); + }); }); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 253ad63afc4..20d5772d3f7 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -124,6 +124,36 @@ const resolvePluginSdkAliasFile = (params: { const resolvePluginSdkAlias = (): string | null => resolvePluginSdkAliasFile({ srcFile: "root-alias.cjs", distFile: "root-alias.cjs" }); +const resolveExtensionApiAlias = (params: { modulePath?: string } = {}): string | null => { + try { + const modulePath = params.modulePath ?? fileURLToPath(import.meta.url); + const packageRoot = resolveOpenClawPackageRootSync({ + cwd: path.dirname(modulePath), + }); + if (!packageRoot) { + return null; + } + + const orderedKinds = resolvePluginSdkAliasCandidateOrder({ + modulePath, + isProduction: process.env.NODE_ENV === "production", + }); + const candidateMap = { + src: path.join(packageRoot, "src", "extensionAPI.ts"), + dist: path.join(packageRoot, "dist", "extensionAPI.js"), + } as const; + for (const kind of orderedKinds) { + const candidate = candidateMap[kind]; + if (fs.existsSync(candidate)) { + return candidate; + } + } + } catch { + // ignore + } + return null; +}; + const cachedPluginSdkExportedSubpaths = new Map(); function listPluginSdkExportedSubpaths(params: { modulePath?: string } = {}): string[] { @@ -172,6 +202,7 @@ const resolvePluginSdkScopedAliasMap = (): Record => { export const __testing = { listPluginSdkAliasCandidates, listPluginSdkExportedSubpaths, + resolveExtensionApiAlias, resolvePluginSdkAliasCandidateOrder, resolvePluginSdkAliasFile, maxPluginRegistryCacheEntries: MAX_PLUGIN_REGISTRY_CACHE_ENTRIES, @@ -701,7 +732,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi return jitiLoader; } const pluginSdkAlias = resolvePluginSdkAlias(); + const extensionApiAlias = resolveExtensionApiAlias(); const aliasMap = { + ...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}), ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), ...resolvePluginSdkScopedAliasMap(), }; diff --git a/tsconfig.json b/tsconfig.json index bc6439e921f..e2f9e4ff97e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ "target": "es2023", "useDefineForClassFields": false, "paths": { + "openclaw/extension-api": ["./src/extensionAPI.ts"], "openclaw/plugin-sdk": ["./src/plugin-sdk/index.ts"], "openclaw/plugin-sdk/*": ["./src/plugin-sdk/*.ts"], "openclaw/plugin-sdk/account-id": ["./src/plugin-sdk/account-id.ts"] diff --git a/tsdown.config.ts b/tsdown.config.ts index acd4fc3e0c8..b1aa8749307 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; +import path from "node:path"; import { defineConfig } from "tsdown"; const env = { @@ -87,6 +89,51 @@ const pluginSdkEntrypoints = [ "keyed-async-queue", ] as const; +function listBundledPluginBuildEntries(): Record { + const extensionsRoot = path.join(process.cwd(), "extensions"); + const entries: Record = {}; + + for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) { + if (!dirent.isDirectory()) { + continue; + } + + const pluginDir = path.join(extensionsRoot, dirent.name); + const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); + if (!fs.existsSync(manifestPath)) { + continue; + } + + const packageJsonPath = path.join(pluginDir, "package.json"); + let packageEntries: string[] = []; + if (fs.existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + openclaw?: { extensions?: unknown }; + }; + packageEntries = Array.isArray(packageJson.openclaw?.extensions) + ? packageJson.openclaw.extensions.filter( + (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, + ) + : []; + } catch { + packageEntries = []; + } + } + + const sourceEntries = packageEntries.length > 0 ? packageEntries : ["./index.ts"]; + for (const entry of sourceEntries) { + const normalizedEntry = entry.replace(/^\.\//, ""); + const entryKey = `extensions/${dirent.name}/${normalizedEntry.replace(/\.[^.]+$/u, "")}`; + entries[entryKey] = path.join("extensions", dirent.name, normalizedEntry); + } + } + + return entries; +} + +const bundledPluginBuildEntries = listBundledPluginBuildEntries(); + export default defineConfig([ nodeBuildConfig({ entry: "src/index.ts", @@ -122,6 +169,12 @@ export default defineConfig([ entry: Object.fromEntries(pluginSdkEntrypoints.map((e) => [e, `src/plugin-sdk/${e}.ts`])), outDir: "dist/plugin-sdk", }), + nodeBuildConfig({ + // Bundle bundled plugin entrypoints so built gateway startup can load JS + // directly from dist/extensions instead of transpiling extensions/*.ts via Jiti. + entry: bundledPluginBuildEntries, + outDir: "dist", + }), nodeBuildConfig({ entry: "src/extensionAPI.ts", }), diff --git a/vitest.config.ts b/vitest.config.ts index 5e0a192d5a3..70011a6a0b8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -58,6 +58,10 @@ export default defineConfig({ resolve: { // Keep this ordered: the base `openclaw/plugin-sdk` alias is a prefix match. alias: [ + { + find: "openclaw/extension-api", + replacement: path.join(repoRoot, "src", "extensionAPI.ts"), + }, ...pluginSdkSubpaths.map((subpath) => ({ find: `openclaw/plugin-sdk/${subpath}`, replacement: path.join(repoRoot, "src", "plugin-sdk", `${subpath}.ts`), From 42837a04bfa9b430f70e17de13a81f116d7c1287 Mon Sep 17 00:00:00 2001 From: "peizhe.chen" Date: Mon, 16 Mar 2026 03:21:11 +0800 Subject: [PATCH 013/943] fix(models): preserve stream usage compat opt-ins (#45733) Preserves explicit `supportsUsageInStreaming` overrides from built-in provider catalogs and user config instead of unconditionally forcing `false` on non-native openai-completions endpoints. Adds `applyNativeStreamingUsageCompat()` to set `supportsUsageInStreaming: true` on ModelStudio (DashScope) and Moonshot models at config build time so their native streaming usage works out of the box. Closes #46142 Co-authored-by: pezy --- src/agents/model-compat.test.ts | 37 +++++++++ src/agents/model-compat.ts | 6 +- src/agents/models-config.plan.ts | 4 +- ...odels-config.providers.modelstudio.test.ts | 52 ++++++------ .../models-config.providers.moonshot.test.ts | 55 ++++++++++++- src/agents/models-config.providers.ts | 79 ++++++++++++++++++- 6 files changed, 203 insertions(+), 30 deletions(-) diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index 733d9a2f47f..bda8ac664db 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -295,6 +295,17 @@ describe("normalizeModelCompat", () => { expect(supportsUsageInStreaming(normalized)).toBe(true); }); + it("preserves explicit supportsUsageInStreaming false on non-native endpoints", () => { + const model = { + ...baseModel(), + provider: "custom-cpa", + baseUrl: "https://proxy.example.com/v1", + compat: { supportsUsageInStreaming: false }, + }; + const normalized = normalizeModelCompat(model); + expect(supportsUsageInStreaming(normalized)).toBe(false); + }); + it("still forces flags off when not explicitly set by user", () => { const model = { ...baseModel(), @@ -348,6 +359,32 @@ describe("normalizeModelCompat", () => { expect(supportsUsageInStreaming(normalized)).toBe(false); expect(supportsStrictMode(normalized)).toBe(false); }); + + it("leaves fully explicit non-native compat untouched", () => { + const model = baseModel(); + model.baseUrl = "https://proxy.example.com/v1"; + model.compat = { + supportsDeveloperRole: false, + supportsUsageInStreaming: true, + supportsStrictMode: true, + }; + const normalized = normalizeModelCompat(model); + expect(normalized).toBe(model); + }); + + it("preserves explicit usage compat when developer role is explicitly enabled", () => { + const model = baseModel(); + model.baseUrl = "https://proxy.example.com/v1"; + model.compat = { + supportsDeveloperRole: true, + supportsUsageInStreaming: true, + supportsStrictMode: true, + }; + const normalized = normalizeModelCompat(model); + expect(supportsDeveloperRole(normalized)).toBe(true); + expect(supportsUsageInStreaming(normalized)).toBe(true); + expect(supportsStrictMode(normalized)).toBe(true); + }); }); describe("isModernModelRef", () => { diff --git a/src/agents/model-compat.ts b/src/agents/model-compat.ts index 46e37733aec..26522da6e67 100644 --- a/src/agents/model-compat.ts +++ b/src/agents/model-compat.ts @@ -66,11 +66,11 @@ export function normalizeModelCompat(model: Model): Model { return model; } const forcedDeveloperRole = compat?.supportsDeveloperRole === true; - const forcedUsageStreaming = compat?.supportsUsageInStreaming === true; + const hasStreamingUsageOverride = compat?.supportsUsageInStreaming !== undefined; const targetStrictMode = compat?.supportsStrictMode ?? false; if ( compat?.supportsDeveloperRole !== undefined && - compat?.supportsUsageInStreaming !== undefined && + hasStreamingUsageOverride && compat?.supportsStrictMode !== undefined ) { return model; @@ -83,7 +83,7 @@ export function normalizeModelCompat(model: Model): Model { ? { ...compat, supportsDeveloperRole: forcedDeveloperRole || false, - supportsUsageInStreaming: forcedUsageStreaming || false, + ...(hasStreamingUsageOverride ? {} : { supportsUsageInStreaming: false }), supportsStrictMode: targetStrictMode, } : { diff --git a/src/agents/models-config.plan.ts b/src/agents/models-config.plan.ts index 601a0edfda1..31794180c3c 100644 --- a/src/agents/models-config.plan.ts +++ b/src/agents/models-config.plan.ts @@ -6,6 +6,7 @@ import { type ExistingProviderConfig, } from "./models-config.merge.js"; import { + applyNativeStreamingUsageCompat, enforceSourceManagedProviderSecrets, normalizeProviders, resolveImplicitProviders, @@ -126,7 +127,8 @@ export async function planOpenClawModelsJson(params: { sourceSecretDefaults: params.sourceConfigForSecrets?.secrets?.defaults, secretRefManagedProviders, }) ?? mergedProviders; - const nextContents = `${JSON.stringify({ providers: secretEnforcedProviders }, null, 2)}\n`; + const finalProviders = applyNativeStreamingUsageCompat(secretEnforcedProviders); + const nextContents = `${JSON.stringify({ providers: finalProviders }, null, 2)}\n`; if (params.existingRaw === nextContents) { return { action: "noop" }; diff --git a/src/agents/models-config.providers.modelstudio.test.ts b/src/agents/models-config.providers.modelstudio.test.ts index df4000cc27d..619146d635c 100644 --- a/src/agents/models-config.providers.modelstudio.test.ts +++ b/src/agents/models-config.providers.modelstudio.test.ts @@ -1,32 +1,36 @@ -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { withEnvAsync } from "../test-utils/env.js"; -import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; -import { buildModelStudioProvider } from "./models-config.providers.js"; - -const modelStudioApiKeyEnv = ["MODELSTUDIO_API", "KEY"].join("_"); +import { + applyNativeStreamingUsageCompat, + buildModelStudioProvider, +} from "./models-config.providers.js"; describe("Model Studio implicit provider", () => { - it("should include modelstudio when MODELSTUDIO_API_KEY is configured", async () => { - const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - const modelStudioApiKey = "test-key"; // pragma: allowlist secret - await withEnvAsync({ [modelStudioApiKeyEnv]: modelStudioApiKey }, async () => { - const providers = await resolveImplicitProvidersForTest({ agentDir }); - expect(providers?.modelstudio).toBeDefined(); - expect(providers?.modelstudio?.apiKey).toBe("MODELSTUDIO_API_KEY"); - expect(providers?.modelstudio?.baseUrl).toBe("https://coding-intl.dashscope.aliyuncs.com/v1"); + it("should opt native Model Studio baseUrls into streaming usage", () => { + const providers = applyNativeStreamingUsageCompat({ + modelstudio: buildModelStudioProvider(), }); + expect(providers?.modelstudio).toBeDefined(); + expect(providers?.modelstudio?.baseUrl).toBe("https://coding-intl.dashscope.aliyuncs.com/v1"); + expect( + providers?.modelstudio?.models?.every( + (model) => model.compat?.supportsUsageInStreaming === true, + ), + ).toBe(true); }); - it("should build the static Model Studio provider catalog", () => { - const provider = buildModelStudioProvider(); - const modelIds = provider.models.map((model) => model.id); - expect(provider.api).toBe("openai-completions"); - expect(provider.baseUrl).toBe("https://coding-intl.dashscope.aliyuncs.com/v1"); - expect(modelIds).toContain("qwen3.5-plus"); - expect(modelIds).toContain("qwen3-coder-plus"); - expect(modelIds).toContain("kimi-k2.5"); + it("should keep streaming usage opt-in disabled for custom Model Studio-compatible baseUrls", () => { + const providers = applyNativeStreamingUsageCompat({ + modelstudio: { + ...buildModelStudioProvider(), + baseUrl: "https://proxy.example.com/v1", + }, + }); + expect(providers?.modelstudio).toBeDefined(); + expect(providers?.modelstudio?.baseUrl).toBe("https://proxy.example.com/v1"); + expect( + providers?.modelstudio?.models?.some( + (model) => model.compat?.supportsUsageInStreaming === true, + ), + ).toBe(false); }); }); diff --git a/src/agents/models-config.providers.moonshot.test.ts b/src/agents/models-config.providers.moonshot.test.ts index 00e1f5949c6..c235266800a 100644 --- a/src/agents/models-config.providers.moonshot.test.ts +++ b/src/agents/models-config.providers.moonshot.test.ts @@ -7,7 +7,11 @@ import { MOONSHOT_CN_BASE_URL, } from "../commands/onboard-auth.models.js"; import { captureEnv } from "../test-utils/env.js"; -import { resolveImplicitProviders } from "./models-config.providers.js"; +import { + applyNativeStreamingUsageCompat, + resolveImplicitProviders, +} from "./models-config.providers.js"; +import { buildMoonshotProvider } from "./models-config.providers.static.js"; describe("moonshot implicit provider (#33637)", () => { it("uses explicit CN baseUrl when provided", async () => { @@ -39,6 +43,31 @@ describe("moonshot implicit provider (#33637)", () => { expect(providers?.moonshot).toBeDefined(); expect(providers?.moonshot?.baseUrl).toBe(MOONSHOT_CN_BASE_URL); expect(providers?.moonshot?.apiKey).toBeDefined(); + expect(providers?.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBeUndefined(); + } finally { + envSnapshot.restore(); + } + }); + + it("keeps streaming usage opt-in unset before the final compat pass", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["MOONSHOT_API_KEY"]); + process.env.MOONSHOT_API_KEY = "sk-test-custom"; + + try { + const providers = await resolveImplicitProviders({ + agentDir, + explicitProviders: { + moonshot: { + baseUrl: "https://proxy.example.com/v1", + api: "openai-completions", + models: [], + }, + }, + }); + expect(providers?.moonshot).toBeDefined(); + expect(providers?.moonshot?.baseUrl).toBe("https://proxy.example.com/v1"); + expect(providers?.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBeUndefined(); } finally { envSnapshot.restore(); } @@ -53,8 +82,32 @@ describe("moonshot implicit provider (#33637)", () => { const providers = await resolveImplicitProviders({ agentDir }); expect(providers?.moonshot).toBeDefined(); expect(providers?.moonshot?.baseUrl).toBe(MOONSHOT_AI_BASE_URL); + expect(providers?.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBeUndefined(); } finally { envSnapshot.restore(); } }); + + it("opts native Moonshot baseUrls into streaming usage only after the final compat pass", () => { + const defaultProviders = applyNativeStreamingUsageCompat({ + moonshot: buildMoonshotProvider(), + }); + expect(defaultProviders.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBe(true); + + const cnProviders = applyNativeStreamingUsageCompat({ + moonshot: { + ...buildMoonshotProvider(), + baseUrl: MOONSHOT_CN_BASE_URL, + }, + }); + expect(cnProviders.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBe(true); + + const customProviders = applyNativeStreamingUsageCompat({ + moonshot: { + ...buildMoonshotProvider(), + baseUrl: "https://proxy.example.com/v1", + }, + }); + expect(customProviders.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBeUndefined(); + }); }); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 03110d3fba5..19d2f1327ba 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -81,6 +81,15 @@ type SecretDefaults = { exec?: string; }; +const MOONSHOT_NATIVE_BASE_URLS = new Set([ + "https://api.moonshot.ai/v1", + "https://api.moonshot.cn/v1", +]); +const MODELSTUDIO_NATIVE_BASE_URLS = new Set([ + "https://coding-intl.dashscope.aliyuncs.com/v1", + "https://coding.dashscope.aliyuncs.com/v1", +]); + const ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/; function normalizeApiKeyConfig(value: string): string { @@ -89,6 +98,65 @@ function normalizeApiKeyConfig(value: string): string { return match?.[1] ?? trimmed; } +function normalizeProviderBaseUrl(baseUrl: string | undefined): string { + const trimmed = baseUrl?.trim(); + if (!trimmed) { + return ""; + } + try { + const url = new URL(trimmed); + url.hash = ""; + url.search = ""; + return url.toString().replace(/\/+$/, "").toLowerCase(); + } catch { + return trimmed.replace(/\/+$/, "").toLowerCase(); + } +} + +function withStreamingUsageCompat(provider: ProviderConfig): ProviderConfig { + if (!Array.isArray(provider.models) || provider.models.length === 0) { + return provider; + } + + let changed = false; + const models = provider.models.map((model) => { + if (model.compat?.supportsUsageInStreaming !== undefined) { + return model; + } + changed = true; + return { + ...model, + compat: { + ...model.compat, + supportsUsageInStreaming: true, + }, + }; + }); + + return changed ? { ...provider, models } : provider; +} + +export function applyNativeStreamingUsageCompat( + providers: Record, +): Record { + let changed = false; + const nextProviders: Record = {}; + + for (const [providerKey, provider] of Object.entries(providers)) { + const normalizedBaseUrl = normalizeProviderBaseUrl(provider.baseUrl); + const isNativeMoonshot = + providerKey === "moonshot" && MOONSHOT_NATIVE_BASE_URLS.has(normalizedBaseUrl); + const isNativeModelStudio = + providerKey === "modelstudio" && MODELSTUDIO_NATIVE_BASE_URLS.has(normalizedBaseUrl); + const nextProvider = + isNativeMoonshot || isNativeModelStudio ? withStreamingUsageCompat(provider) : provider; + nextProviders[providerKey] = nextProvider; + changed ||= nextProvider !== provider; + } + + return changed ? nextProviders : providers; +} + function resolveEnvApiKeyVarName( provider: string, env: NodeJS.ProcessEnv = process.env, @@ -684,7 +752,16 @@ const SIMPLE_IMPLICIT_PROVIDER_LOADERS: ImplicitProviderLoader[] = [ apiKey, })), withApiKey("qianfan", async ({ apiKey }) => ({ ...buildQianfanProvider(), apiKey })), - withApiKey("modelstudio", async ({ apiKey }) => ({ ...buildModelStudioProvider(), apiKey })), + withApiKey("modelstudio", async ({ apiKey, explicitProvider }) => { + const explicitBaseUrl = explicitProvider?.baseUrl; + return { + ...buildModelStudioProvider(), + ...(typeof explicitBaseUrl === "string" && explicitBaseUrl.trim() + ? { baseUrl: explicitBaseUrl.trim() } + : {}), + apiKey, + }; + }), withApiKey("openrouter", async ({ apiKey }) => ({ ...buildOpenrouterProvider(), apiKey })), withApiKey("nvidia", async ({ apiKey }) => ({ ...buildNvidiaProvider(), apiKey })), withApiKey("kilocode", async ({ apiKey }) => ({ From 51631e5797d8191b1ecfc5f126f1764ac41eb74b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 12:27:29 -0700 Subject: [PATCH 014/943] Plugins: reserve context engine ownership --- src/context-engine/context-engine.test.ts | 24 +++++++-- src/context-engine/registry.ts | 34 +++++++++--- src/plugins/loader.test.ts | 65 +++++++++++++++++++++++ src/plugins/registry.ts | 22 +++++++- 4 files changed, 133 insertions(+), 12 deletions(-) diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index cd0f2f50439..5cdc03a7114 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -231,18 +231,36 @@ describe("Registry tests", () => { expect(Array.isArray(ids)).toBe(true); }); - it("registering the same id overwrites the previous factory", () => { + it("registering the same id with the same owner refreshes the factory", () => { const factory1 = () => new MockContextEngine(); const factory2 = () => new MockContextEngine(); - registerContextEngine("reg-overwrite", factory1); + expect(registerContextEngine("reg-overwrite", factory1, { owner: "owner-a" })).toEqual({ + ok: true, + }); expect(getContextEngineFactory("reg-overwrite")).toBe(factory1); - registerContextEngine("reg-overwrite", factory2); + expect(registerContextEngine("reg-overwrite", factory2, { owner: "owner-a" })).toEqual({ + ok: true, + }); expect(getContextEngineFactory("reg-overwrite")).toBe(factory2); expect(getContextEngineFactory("reg-overwrite")).not.toBe(factory1); }); + it("rejects context engine registrations from a different owner", () => { + const factory1 = () => new MockContextEngine(); + const factory2 = () => new MockContextEngine(); + + expect(registerContextEngine("reg-owner-guard", factory1, { owner: "owner-a" })).toEqual({ + ok: true, + }); + expect(registerContextEngine("reg-owner-guard", factory2, { owner: "owner-b" })).toEqual({ + ok: false, + existingOwner: "owner-a", + }); + expect(getContextEngineFactory("reg-owner-guard")).toBe(factory1); + }); + it("shares registered engines across duplicate module copies", async () => { const registryUrl = new URL("./registry.ts", import.meta.url).href; const suffix = Date.now().toString(36); diff --git a/src/context-engine/registry.ts b/src/context-engine/registry.ts index d73266c62de..8b5474dc127 100644 --- a/src/context-engine/registry.ts +++ b/src/context-engine/registry.ts @@ -7,6 +7,7 @@ import type { ContextEngine } from "./types.js"; * Supports async creation for engines that need DB connections etc. */ export type ContextEngineFactory = () => ContextEngine | Promise; +export type ContextEngineRegistrationResult = { ok: true } | { ok: false; existingOwner: string }; // --------------------------------------------------------------------------- // Registry (module-level singleton) @@ -15,7 +16,13 @@ export type ContextEngineFactory = () => ContextEngine | Promise; const CONTEXT_ENGINE_REGISTRY_STATE = Symbol.for("openclaw.contextEngineRegistryState"); type ContextEngineRegistryState = { - engines: Map; + engines: Map< + string, + { + factory: ContextEngineFactory; + owner: string; + } + >; }; // Keep context-engine registrations process-global so duplicated dist chunks @@ -26,7 +33,7 @@ function getContextEngineRegistryState(): ContextEngineRegistryState { }; if (!globalState[CONTEXT_ENGINE_REGISTRY_STATE]) { globalState[CONTEXT_ENGINE_REGISTRY_STATE] = { - engines: new Map(), + engines: new Map(), }; } return globalState[CONTEXT_ENGINE_REGISTRY_STATE]; @@ -35,15 +42,26 @@ function getContextEngineRegistryState(): ContextEngineRegistryState { /** * Register a context engine implementation under the given id. */ -export function registerContextEngine(id: string, factory: ContextEngineFactory): void { - getContextEngineRegistryState().engines.set(id, factory); +export function registerContextEngine( + id: string, + factory: ContextEngineFactory, + opts?: { owner?: string }, +): ContextEngineRegistrationResult { + const owner = opts?.owner?.trim() || "core"; + const registry = getContextEngineRegistryState().engines; + const existing = registry.get(id); + if (existing && existing.owner !== owner) { + return { ok: false, existingOwner: existing.owner }; + } + registry.set(id, { factory, owner }); + return { ok: true }; } /** * Return the factory for a registered engine, or undefined. */ export function getContextEngineFactory(id: string): ContextEngineFactory | undefined { - return getContextEngineRegistryState().engines.get(id); + return getContextEngineRegistryState().engines.get(id)?.factory; } /** @@ -73,13 +91,13 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise { ).toBe(true); }); + it("rejects plugin context engine ids reserved by core", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "context-engine-core-collision", + filename: "context-engine-core-collision.cjs", + body: `module.exports = { id: "context-engine-core-collision", register(api) { + api.registerContextEngine("legacy", () => ({})); +} };`, + }); + + const registry = loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: ["context-engine-core-collision"], + }, + }); + + expect( + registry.diagnostics.some( + (diag) => + diag.level === "error" && + diag.pluginId === "context-engine-core-collision" && + diag.message === "context engine id reserved by core: legacy", + ), + ).toBe(true); + }); + + it("rejects duplicate plugin context engine ids", () => { + useNoBundledPlugins(); + const first = writePlugin({ + id: "context-engine-owner-a", + filename: "context-engine-owner-a.cjs", + body: `module.exports = { id: "context-engine-owner-a", register(api) { + api.registerContextEngine("shared-context-engine-loader-test", () => ({})); +} };`, + }); + const second = writePlugin({ + id: "context-engine-owner-b", + filename: "context-engine-owner-b.cjs", + body: `module.exports = { id: "context-engine-owner-b", register(api) { + api.registerContextEngine("shared-context-engine-loader-test", () => ({})); +} };`, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [first.file, second.file] }, + allow: ["context-engine-owner-a", "context-engine-owner-b"], + }, + }, + }); + + expect( + registry.diagnostics.some( + (diag) => + diag.level === "error" && + diag.pluginId === "context-engine-owner-b" && + diag.message === + "context engine already registered: shared-context-engine-loader-test (plugin:context-engine-owner-a)", + ), + ).toBe(true); + }); + it("requires plugin CLI registrars to declare explicit command roots", () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index c1c63cc96cb..952c8d7744b 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -15,6 +15,7 @@ import { normalizePluginHttpPath } from "./http-path.js"; import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; import { normalizeRegisteredProvider } from "./provider-validation.js"; import type { PluginRuntime } from "./runtime/types.js"; +import { defaultSlotIdForKey } from "./slots.js"; import { isPluginHookName, isPromptInjectionHookName, @@ -653,7 +654,26 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerCli: (registrar, opts) => registerCli(record, registrar, opts), registerService: (service) => registerService(record, service), registerCommand: (command) => registerCommand(record, command), - registerContextEngine: (id, factory) => registerContextEngine(id, factory), + registerContextEngine: (id, factory) => { + if (id === defaultSlotIdForKey("contextEngine")) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `context engine id reserved by core: ${id}`, + }); + return; + } + const result = registerContextEngine(id, factory, { owner: `plugin:${record.id}` }); + if (!result.ok) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `context engine already registered: ${id} (${result.existingOwner})`, + }); + } + }, resolvePath: (input: string) => resolveUserPath(input), on: (hookName, handler, opts) => registerTypedHook(record, hookName, handler, opts, params.hookPolicy), From 4a7fbe090af47f63d59bceb8f54cb6106c04a397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dinh?= <82420070+No898@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:40:35 +0100 Subject: [PATCH 015/943] docs(zalo): document current Marketplace bot behavior (openclaw#47552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified: - pnpm check:docs Co-authored-by: Tomáš Dinh <82420070+No898@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/channels/zalo.md | 89 ++++++++++++++++++++++++++++++------------- 2 files changed, 64 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc9aa9435ae..2b85cd40bb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029) - Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob. - Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280. +- Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898. ### Fixes diff --git a/docs/channels/zalo.md b/docs/channels/zalo.md index 77b288b0ab7..cf53b574e42 100644 --- a/docs/channels/zalo.md +++ b/docs/channels/zalo.md @@ -7,7 +7,7 @@ title: "Zalo" # Zalo (Bot API) -Status: experimental. DMs are supported; group handling is available with explicit group policy controls. +Status: experimental. DMs are supported. The [Capabilities](#capabilities) section below reflects current Marketplace-bot behavior. ## Plugin required @@ -25,7 +25,7 @@ Zalo ships as a plugin and is not bundled with the core install. - Or pick **Zalo** in onboarding and confirm the install prompt 2. Set the token: - Env: `ZALO_BOT_TOKEN=...` - - Or config: `channels.zalo.botToken: "..."`. + - Or config: `channels.zalo.accounts.default.botToken: "..."`. 3. Restart the gateway (or finish onboarding). 4. DM access is pairing by default; approve the pairing code on first contact. @@ -36,8 +36,12 @@ Minimal config: channels: { zalo: { enabled: true, - botToken: "12345689:abc-xyz", - dmPolicy: "pairing", + accounts: { + default: { + botToken: "12345689:abc-xyz", + dmPolicy: "pairing", + }, + }, }, }, } @@ -48,10 +52,13 @@ Minimal config: Zalo is a Vietnam-focused messaging app; its Bot API lets the Gateway run a bot for 1:1 conversations. It is a good fit for support or notifications where you want deterministic routing back to Zalo. +This page reflects current OpenClaw behavior for **Zalo Bot Creator / Marketplace bots**. +**Zalo Official Account (OA) bots** are a different Zalo product surface and may behave differently. + - A Zalo Bot API channel owned by the Gateway. - Deterministic routing: replies go back to Zalo; the model never chooses channels. - DMs share the agent's main session. -- Groups are supported with policy controls (`groupPolicy` + `groupAllowFrom`) and default to fail-closed allowlist behavior. +- The [Capabilities](#capabilities) section below shows current Marketplace-bot support. ## Setup (fast path) @@ -59,7 +66,7 @@ It is a good fit for support or notifications where you want deterministic routi 1. Go to [https://bot.zaloplatforms.com](https://bot.zaloplatforms.com) and sign in. 2. Create a new bot and configure its settings. -3. Copy the bot token (format: `12345689:abc-xyz`). +3. Copy the full bot token (typically `numeric_id:secret`). For Marketplace bots, the usable runtime token may appear in the bot's welcome message after creation. ### 2) Configure the token (env or config) @@ -70,13 +77,19 @@ Example: channels: { zalo: { enabled: true, - botToken: "12345689:abc-xyz", - dmPolicy: "pairing", + accounts: { + default: { + botToken: "12345689:abc-xyz", + dmPolicy: "pairing", + }, + }, }, }, } ``` +If you later move to a Zalo bot surface where groups are available, you can add group-specific config such as `groupPolicy` and `groupAllowFrom` explicitly. For current Marketplace-bot behavior, see [Capabilities](#capabilities). + Env option: `ZALO_BOT_TOKEN=...` (works for the default account only). Multi-account support: use `channels.zalo.accounts` with per-account tokens and optional `name`. @@ -109,14 +122,23 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and ## Access control (Groups) +For **Zalo Bot Creator / Marketplace bots**, group support was not available in practice because the bot could not be added to a group at all. + +That means the group-related config keys below exist in the schema, but were not usable for Marketplace bots: + - `channels.zalo.groupPolicy` controls group inbound handling: `open | allowlist | disabled`. -- Default behavior is fail-closed: `allowlist`. - `channels.zalo.groupAllowFrom` restricts which sender IDs can trigger the bot in groups. - If `groupAllowFrom` is unset, Zalo falls back to `allowFrom` for sender checks. -- `groupPolicy: "disabled"` blocks all group messages. -- `groupPolicy: "open"` allows any group member (mention-gated). - Runtime note: if `channels.zalo` is missing entirely, runtime still falls back to `groupPolicy="allowlist"` for safety. +The group policy values (when group access is available on your bot surface) are: + +- `groupPolicy: "disabled"` — blocks all group messages. +- `groupPolicy: "open"` — allows any group member (mention-gated). +- `groupPolicy: "allowlist"` — fail-closed default; only allowed senders are accepted. + +If you are using a different Zalo bot product surface and have verified working group behavior, document that separately rather than assuming it matches the Marketplace-bot flow. + ## Long-polling vs webhook - Default: long-polling (no public URL required). @@ -133,23 +155,36 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and ## Supported message types +For a quick support snapshot, see [Capabilities](#capabilities). The notes below add detail where the behavior needs extra context. + - **Text messages**: Full support with 2000 character chunking. -- **Image messages**: Download and process inbound images; send images via `sendPhoto`. -- **Stickers**: Logged but not fully processed (no agent response). -- **Unsupported types**: Logged (e.g., messages from protected users). +- **Plain URLs in text**: Behave like normal text input. +- **Link previews / rich link cards**: See the Marketplace-bot status in [Capabilities](#capabilities); they did not reliably trigger a reply. +- **Image messages**: See the Marketplace-bot status in [Capabilities](#capabilities); inbound image handling was unreliable (typing indicator without a final reply). +- **Stickers**: See the Marketplace-bot status in [Capabilities](#capabilities). +- **Voice notes / audio files / video / generic file attachments**: See the Marketplace-bot status in [Capabilities](#capabilities). +- **Unsupported types**: Logged (for example, messages from protected users). ## Capabilities -| Feature | Status | -| --------------- | -------------------------------------------------------- | -| Direct messages | ✅ Supported | -| Groups | ⚠️ Supported with policy controls (allowlist by default) | -| Media (images) | ✅ Supported | -| Reactions | ❌ Not supported | -| Threads | ❌ Not supported | -| Polls | ❌ Not supported | -| Native commands | ❌ Not supported | -| Streaming | ⚠️ Blocked (2000 char limit) | +This table summarizes current **Zalo Bot Creator / Marketplace bot** behavior in OpenClaw. + +| Feature | Status | +| --------------------------- | --------------------------------------- | +| Direct messages | ✅ Supported | +| Groups | ❌ Not available for Marketplace bots | +| Media (inbound images) | ⚠️ Limited / verify in your environment | +| Media (outbound images) | ⚠️ Not re-tested for Marketplace bots | +| Plain URLs in text | ✅ Supported | +| Link previews | ⚠️ Unreliable for Marketplace bots | +| Reactions | ❌ Not supported | +| Stickers | ⚠️ No agent reply for Marketplace bots | +| Voice notes / audio / video | ⚠️ No agent reply for Marketplace bots | +| File attachments | ⚠️ No agent reply for Marketplace bots | +| Threads | ❌ Not supported | +| Polls | ❌ Not supported | +| Native commands | ❌ Not supported | +| Streaming | ⚠️ Blocked (2000 char limit) | ## Delivery targets (CLI/cron) @@ -175,6 +210,8 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and Full configuration: [Configuration](/gateway/configuration) +The flat top-level keys (`channels.zalo.botToken`, `channels.zalo.dmPolicy`, and similar) are a legacy single-account shorthand. Prefer `channels.zalo.accounts..*` for new configs. Both forms are still documented here because they exist in the schema. + Provider options: - `channels.zalo.enabled`: enable/disable channel startup. @@ -182,7 +219,7 @@ Provider options: - `channels.zalo.tokenFile`: read token from a regular file path. Symlinks are rejected. - `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). - `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs. -- `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist). +- `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist). Present in config; see [Capabilities](#capabilities) and [Access control (Groups)](#access-control-groups) for current Marketplace-bot behavior. - `channels.zalo.groupAllowFrom`: group sender allowlist (user IDs). Falls back to `allowFrom` when unset. - `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5). - `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required). @@ -198,7 +235,7 @@ Multi-account options: - `channels.zalo.accounts..enabled`: enable/disable account. - `channels.zalo.accounts..dmPolicy`: per-account DM policy. - `channels.zalo.accounts..allowFrom`: per-account allowlist. -- `channels.zalo.accounts..groupPolicy`: per-account group policy. +- `channels.zalo.accounts..groupPolicy`: per-account group policy. Present in config; see [Capabilities](#capabilities) and [Access control (Groups)](#access-control-groups) for current Marketplace-bot behavior. - `channels.zalo.accounts..groupAllowFrom`: per-account group sender allowlist. - `channels.zalo.accounts..webhookUrl`: per-account webhook URL. - `channels.zalo.accounts..webhookSecret`: per-account webhook secret. From a2080421a115d3289dbce88f562b03e6e34c6680 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:42:39 +0100 Subject: [PATCH 016/943] Docs: move release runbook to maintainer repo (#47532) * Docs: redact private release setup * Docs: tighten release order * Docs: move release runbook to maintainer repo * Docs: delete public mac release page * Docs: remove zh-CN mac release page * Docs: turn release checklist into release policy * Docs: point release policy to private docs * Docs: regenerate zh-CN release policy pages * Docs: preserve Doctor in zh-CN hubs * Docs: fix zh-CN polls label * Docs: tighten docs i18n term guardrails * Docs: enforce zh-CN glossary coverage --- AGENTS.md | 44 ++--- docs/.i18n/glossary.zh-CN.json | 16 ++ docs/docs.json | 8 +- docs/platforms/mac/release.md | 90 ---------- docs/reference/RELEASING.md | 169 +++---------------- docs/start/hubs.md | 3 +- docs/zh-CN/AGENTS.md | 4 +- docs/zh-CN/platforms/mac/release.md | 92 ----------- docs/zh-CN/reference/RELEASING.md | 137 ++++------------ docs/zh-CN/start/hubs.md | 25 +-- package.json | 3 +- scripts/check-docs-i18n-glossary.mjs | 237 +++++++++++++++++++++++++++ scripts/docs-i18n/prompt.go | 17 +- 13 files changed, 358 insertions(+), 487 deletions(-) delete mode 100644 docs/platforms/mac/release.md delete mode 100644 docs/zh-CN/platforms/mac/release.md create mode 100644 scripts/check-docs-i18n-glossary.mjs diff --git a/AGENTS.md b/AGENTS.md index 245eedf3d4b..1197f6fb48f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,8 @@ - `docs/zh-CN/**` is generated; do not edit unless the user explicitly asks. - Pipeline: update English docs → adjust glossary (`docs/.i18n/glossary.zh-CN.json`) → run `scripts/docs-i18n` → apply targeted fixes only if instructed. +- Before rerunning `scripts/docs-i18n`, add glossary entries for any new technical terms, page titles, or short nav labels that must stay in English or use a fixed translation (for example `Doctor` or `Polls`). +- `pnpm docs:check-i18n-glossary` enforces glossary coverage for changed English doc titles and short internal doc labels before translation reruns. - Translation memory: `docs/.i18n/zh-CN.tm.jsonl` (generated). - See `docs/.i18n/README.md`. - The pipeline can be slow/inefficient; if it’s dragging, ping @jospalmbier on Discord instead of hacking around it. @@ -97,7 +99,7 @@ - Prefer Bun for TypeScript execution (scripts, dev, tests): `bun ` / `bunx `. - Run CLI in dev: `pnpm openclaw ...` (bun) or `pnpm dev`. - Node remains supported for running built output (`dist/*`) and production installs. -- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. Release checklist: `docs/platforms/mac/release.md`. +- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. - Type-check/build: `pnpm build` - TypeScript checks: `pnpm tsgo` - Lint/format: `pnpm check` @@ -179,7 +181,7 @@ - Pi sessions live under `~/.openclaw/sessions/` by default; the base directory is not configurable. - Environment variables: see `~/.profile`. - Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples. -- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them. +- Release flow: use the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md) for the actual runbook; use `docs/reference/RELEASING.md` for the public release policy. ## GHSA (Repo Advisory) Patch/Publish @@ -256,14 +258,13 @@ - If shared guardrails are available locally, review them; otherwise follow this repo's guidance. - SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code. - Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync. -- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), `docs/platforms/mac/release.md` (APP_VERSION/APP_BUILD examples), Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION). +- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), and Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION). - "Bump version everywhere" means all version locations above **except** `appcast.xml` (only touch appcast when cutting a new macOS Sparkle release). - **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch. - **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators. - iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`. - A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit. -- Release signing/notary keys are managed outside the repo; follow internal release docs. -- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are expected in your environment (per internal release docs). +- Release signing/notary credentials are managed outside the repo; maintainers keep that setup in the private [maintainer release docs](https://github.com/openclaw/maintainers/tree/main/release). - **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes. - **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks. - **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested. @@ -290,35 +291,12 @@ - Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step. - Beta release guardrail: when using a beta Git tag (for example `vYYYY.M.D-beta.N`), publish npm with a matching beta version suffix (for example `YYYY.M.D-beta.N`) rather than a plain version on `--tag beta`; otherwise the plain version name gets consumed/blocked. -## NPM + 1Password (publish/verify) +## Release Auth -- Use the 1password skill; all `op` commands must run inside a fresh tmux session. -- Correct 1Password path for npm release auth: `op://Private/Npmjs` (use that item; OTP stays `op://Private/Npmjs/one-time password?attribute=otp`). -- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on). -- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`. -- Publish: `npm publish --access public --otp=""` (run from the package dir). -- Verify without local npmrc side effects: `npm view version --userconfig "$(mktemp)"`. -- Kill the tmux session after publish. - -## Plugin Release Fast Path (no core `openclaw` publish) - -- Release only already-on-npm plugins. Source list is in `docs/reference/RELEASING.md` under "Current npm plugin list". -- Run all CLI `op` calls and `npm publish` inside tmux to avoid hangs/interruption: - - `tmux new -d -s release-plugins-$(date +%Y%m%d-%H%M%S)` - - `eval "$(op signin --account my.1password.com)"` -- 1Password helpers: - - password used by `npm login`: - `op item get Npmjs --format=json | jq -r '.fields[] | select(.id=="password").value'` - - OTP: - `op read 'op://Private/Npmjs/one-time password?attribute=otp'` -- Fast publish loop (local helper script in `/tmp` is fine; keep repo clean): - - compare local plugin `version` to `npm view version` - - only run `npm publish --access public --otp=""` when versions differ - - skip if package is missing on npm or version already matches. -- Keep `openclaw` untouched: never run publish from repo root unless explicitly requested. -- Post-check for each release: - - per-plugin: `npm view @openclaw/ version --userconfig "$(mktemp)"` should be `2026.2.17` - - core guard: `npm view openclaw version --userconfig "$(mktemp)"` should stay at previous version unless explicitly requested. +- Core `openclaw` publish uses GitHub trusted publishing; do not use `NPM_TOKEN` or the plugin OTP flow for core releases. +- Separate `@openclaw/*` plugin publishes use a different maintainer-only auth flow. +- Plugin scope: only publish already-on-npm `@openclaw/*` plugins. Bundled disk-tree-only plugins stay out. +- Maintainers: private 1Password item names, tmux rules, plugin publish helpers, and local mac signing/notary setup live in the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md). ## Changelog Release Notes diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index bde108074c2..f8941862b94 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -123,6 +123,22 @@ "source": "Network model", "target": "网络模型" }, + { + "source": "Doctor", + "target": "Doctor" + }, + { + "source": "Polls", + "target": "投票" + }, + { + "source": "Release Policy", + "target": "发布策略" + }, + { + "source": "Release policy", + "target": "发布策略" + }, { "source": "for full details", "target": "了解详情" diff --git a/docs/docs.json b/docs/docs.json index 98c88e0177c..8855a7335d6 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -469,7 +469,7 @@ }, { "source": "/mac/release", - "destination": "/platforms/mac/release" + "destination": "/reference/RELEASING" }, { "source": "/mac/remote", @@ -1166,7 +1166,6 @@ "platforms/mac/permissions", "platforms/mac/remote", "platforms/mac/signing", - "platforms/mac/release", "platforms/mac/bundled-gateway", "platforms/mac/xpc", "platforms/mac/skills", @@ -1351,7 +1350,7 @@ "pages": ["reference/credits"] }, { - "group": "Release notes", + "group": "Release policy", "pages": ["reference/RELEASING", "reference/test"] }, { @@ -1750,7 +1749,6 @@ "zh-CN/platforms/mac/permissions", "zh-CN/platforms/mac/remote", "zh-CN/platforms/mac/signing", - "zh-CN/platforms/mac/release", "zh-CN/platforms/mac/bundled-gateway", "zh-CN/platforms/mac/xpc", "zh-CN/platforms/mac/skills", @@ -1933,7 +1931,7 @@ "pages": ["zh-CN/reference/credits"] }, { - "group": "发布说明", + "group": "发布策略", "pages": ["zh-CN/reference/RELEASING", "zh-CN/reference/test"] }, { diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md deleted file mode 100644 index 5276d46848e..00000000000 --- a/docs/platforms/mac/release.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -summary: "OpenClaw macOS release checklist (Sparkle feed, packaging, signing)" -read_when: - - Cutting or validating a OpenClaw macOS release - - Updating the Sparkle appcast or feed assets -title: "macOS Release" ---- - -# OpenClaw macOS release (Sparkle) - -This app now ships Sparkle auto-updates. Release builds must be Developer ID–signed, zipped, and published with a signed appcast entry. - -## Prereqs - -- Developer ID Application cert installed (example: `Developer ID Application: ()`). -- Sparkle private key path set in the environment as `SPARKLE_PRIVATE_KEY_FILE` (path to your Sparkle ed25519 private key; public key baked into Info.plist). If it is missing, check `~/.profile`. -- Notary credentials (keychain profile or API key) for `xcrun notarytool` if you want Gatekeeper-safe DMG/zip distribution. - - We use a Keychain profile named `openclaw-notary`, created from App Store Connect API key env vars in your shell profile: - - `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID` - - `echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > /tmp/openclaw-notary.p8` - - `xcrun notarytool store-credentials "openclaw-notary" --key /tmp/openclaw-notary.p8 --key-id "$APP_STORE_CONNECT_KEY_ID" --issuer "$APP_STORE_CONNECT_ISSUER_ID"` -- `pnpm` deps installed (`pnpm install --config.node-linker=hoisted`). -- Sparkle tools are fetched automatically via SwiftPM at `apps/macos/.build/artifacts/sparkle/Sparkle/bin/` (`sign_update`, `generate_appcast`, etc.). - -## Build & package - -Notes: - -- `APP_BUILD` maps to `CFBundleVersion`/`sparkle:version`; keep it numeric + monotonic (no `-beta`), or Sparkle compares it as equal. -- If `APP_BUILD` is omitted, `scripts/package-mac-app.sh` derives a Sparkle-safe default from `APP_VERSION` (`YYYYMMDDNN`: stable defaults to `90`, prereleases use a suffix-derived lane) and uses the higher of that value and git commit count. -- You can still override `APP_BUILD` explicitly when release engineering needs a specific monotonic value. -- For `BUILD_CONFIG=release`, `scripts/package-mac-app.sh` now defaults to universal (`arm64 x86_64`) automatically. You can still override with `BUILD_ARCHS=arm64` or `BUILD_ARCHS=x86_64`. For local/dev builds (`BUILD_CONFIG=debug`), it defaults to the current architecture (`$(uname -m)`). -- Use `scripts/package-mac-dist.sh` for release artifacts (zip + DMG + notarization). Use `scripts/package-mac-app.sh` for local/dev packaging. - -```bash -# From repo root; set release IDs so Sparkle feed is enabled. -# This command builds release artifacts without notarization. -# APP_BUILD must be numeric + monotonic for Sparkle compare. -# Default is auto-derived from APP_VERSION when omitted. -SKIP_NOTARIZE=1 \ -BUNDLE_ID=ai.openclaw.mac \ -APP_VERSION=2026.3.13 \ -BUILD_CONFIG=release \ -SIGN_IDENTITY="Developer ID Application: ()" \ -scripts/package-mac-dist.sh - -# `package-mac-dist.sh` already creates the zip + DMG. -# If you used `package-mac-app.sh` directly instead, create them manually: -# If you want notarization/stapling in this step, use the NOTARIZE command below. -ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.13.zip - -# Optional: build a styled DMG for humans (drag to /Applications) -scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.13.dmg - -# Recommended: build + notarize/staple zip + DMG -# First, create a keychain profile once: -# xcrun notarytool store-credentials "openclaw-notary" \ -# --apple-id "" --team-id "" --password "" -NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \ -BUNDLE_ID=ai.openclaw.mac \ -APP_VERSION=2026.3.13 \ -BUILD_CONFIG=release \ -SIGN_IDENTITY="Developer ID Application: ()" \ -scripts/package-mac-dist.sh - -# Optional: ship dSYM alongside the release -ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.3.13.dSYM.zip -``` - -## Appcast entry - -Use the release note generator so Sparkle renders formatted HTML notes: - -```bash -SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.3.13.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml -``` - -Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. -Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing. - -## Publish & verify - -- Upload `OpenClaw-2026.3.13.zip` (and `OpenClaw-2026.3.13.dSYM.zip`) to the GitHub release for tag `v2026.3.13`. -- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`. -- Sanity checks: - - `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200. - - `curl -I ` returns 200 after assets upload. - - On a previous public build, run “Check for Updates…” from the About tab and verify Sparkle installs the new build cleanly. - -Definition of done: signed app + appcast are published, update flow works from an older installed version, and release assets are attached to the GitHub release. diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index d94f3866c83..275675c7dba 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -1,161 +1,42 @@ --- -title: "Release Checklist" -summary: "Step-by-step release checklist for npm + macOS app" +title: "Release Policy" +summary: "Public release channels, version naming, and cadence" read_when: - - Cutting a new npm release - - Cutting a new macOS app release - - Verifying metadata before publishing + - Looking for public release channel definitions + - Looking for version naming and cadence --- -# Release Checklist (npm + macOS) +# Release Policy -Use `pnpm` from the repo root with Node 24 by default. Node 22 LTS, currently `22.16+`, remains supported for compatibility. Keep the working tree clean before tagging/publishing. +OpenClaw has three public release lanes: -## Operator trigger +- stable: tagged releases that publish to npm `latest` +- beta: prerelease tags that publish to npm `beta` +- dev: the moving head of `main` -When the operator says “release”, immediately do this preflight (no extra questions unless blocked): - -- Read this doc and `docs/platforms/mac/release.md`. -- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set (SPARKLE_PRIVATE_KEY_FILE should live in `~/.profile`). -- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed. - -## Versioning - -Current OpenClaw releases use date-based versioning. +## Version naming - Stable release version: `YYYY.M.D` - Git tag: `vYYYY.M.D` - - Examples from repo history: `v2026.2.26`, `v2026.3.8` - Beta prerelease version: `YYYY.M.D-beta.N` - Git tag: `vYYYY.M.D-beta.N` - - Examples from repo history: `v2026.2.15-beta.1`, `v2026.3.8-beta.1` -- Fallback correction tag: `vYYYY.M.D-N` - - Use only as a last-resort recovery tag when a published immutable release burned the original stable tag and you cannot reuse it. - - The npm package version stays `YYYY.M.D`; the `-N` suffix is only for the git tag and GitHub release. - - Prefer betas for normal pre-release iteration, then cut a clean stable tag once ready. -- Use the same version string everywhere, minus the leading `v` where Git tags are not used: - - `package.json`: `2026.3.8` - - Git tag: `v2026.3.8` - - GitHub release title: `openclaw 2026.3.8` -- Do not zero-pad month or day. Use `2026.3.8`, not `2026.03.08`. -- Stable and beta are npm dist-tags, not separate release lines: - - `latest` = stable - - `beta` = prerelease/testing -- Dev is the moving head of `main`, not a normal git-tagged release. -- The tag-triggered preview run accepts stable, beta, and fallback correction tags, and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date. +- Do not zero-pad month or day +- `latest` means the current stable npm release +- `beta` means the current prerelease npm release +- Beta releases may ship before the macOS app catches up -Historical note: +## Release cadence -- Older tags such as `v2026.1.11-1`, `v2026.2.6-3`, and `v2.0.0-beta2` exist in repo history. -- Treat correction tags as a fallback-only escape hatch. New releases should still use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta. +- Releases move beta-first +- Stable follows only after the latest beta is validated +- Detailed release procedure, approvals, credentials, and recovery notes are + maintainer-only -1. **Version & metadata** +## Public references -- [ ] Bump `package.json` version (e.g., `2026.1.29`). -- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs. -- [ ] Update CLI/version strings in [`src/version.ts`](https://github.com/openclaw/openclaw/blob/main/src/version.ts) and the Baileys user agent in [`src/web/session.ts`](https://github.com/openclaw/openclaw/blob/main/src/web/session.ts). -- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`openclaw.mjs`](https://github.com/openclaw/openclaw/blob/main/openclaw.mjs) for `openclaw`. -- [ ] If dependencies changed, run `pnpm install` so `pnpm-lock.yaml` is current. +- [`.github/workflows/openclaw-npm-release.yml`](https://github.com/openclaw/openclaw/blob/main/.github/workflows/openclaw-npm-release.yml) +- [`scripts/openclaw-npm-release-check.ts`](https://github.com/openclaw/openclaw/blob/main/scripts/openclaw-npm-release-check.ts) -2. **Build & artifacts** - -- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/openclaw/openclaw/blob/main/src/canvas-host/a2ui/a2ui.bundle.js). -- [ ] `pnpm run build` (regenerates `dist/`). -- [ ] Verify npm package `files` includes all required `dist/*` folders (notably `dist/node-host/**` and `dist/acp/**` for headless node + ACP CLI). -- [ ] Confirm `dist/build-info.json` exists and includes the expected `commit` hash (CLI banner uses this for npm installs). -- [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it). - -3. **Changelog & docs** - -- [ ] Update `CHANGELOG.md` with user-facing highlights (create the file if missing); keep entries strictly descending by version. -- [ ] Ensure README examples/flags match current CLI behavior (notably new commands or options). - -4. **Validation** - -- [ ] `pnpm build` -- [ ] `pnpm check` -- [ ] `pnpm test` (or `pnpm test:coverage` if you need coverage output) -- [ ] `pnpm release:check` (verifies npm pack contents) -- [ ] If `pnpm config:docs:check` fails as part of release validation and the config-surface change is intentional, run `pnpm config:docs:gen`, review `docs/.generated/config-baseline.json` and `docs/.generated/config-baseline.jsonl`, commit the updated baselines, then rerun `pnpm release:check`. -- [ ] `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` (Docker install smoke test, fast path; required before release) - - If the immediate previous npm release is known broken, set `OPENCLAW_INSTALL_SMOKE_PREVIOUS=` or `OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS=1` for the preinstall step. -- [ ] (Optional) Full installer smoke (adds non-root + CLI coverage): `pnpm test:install:smoke` -- [ ] (Optional) Installer E2E (Docker, runs `curl -fsSL https://openclaw.ai/install.sh | bash`, onboards, then runs real tool calls): - - `pnpm test:install:e2e:openai` (requires `OPENAI_API_KEY`) - - `pnpm test:install:e2e:anthropic` (requires `ANTHROPIC_API_KEY`) - - `pnpm test:install:e2e` (requires both keys; runs both providers) -- [ ] (Optional) Spot-check the web gateway if your changes affect send/receive paths. - -5. **macOS app (Sparkle)** - -- [ ] Build + sign the macOS app, then zip it for distribution. -- [ ] Generate the Sparkle appcast (HTML notes via [`scripts/make_appcast.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/make_appcast.sh)) and update `appcast.xml`. -- [ ] Keep the app zip (and optional dSYM zip) ready to attach to the GitHub release. -- [ ] Follow [macOS release](/platforms/mac/release) for the exact commands and required env vars. - - `APP_BUILD` must be numeric + monotonic (no `-beta`) so Sparkle compares versions correctly. - - If notarizing, use the `openclaw-notary` keychain profile created from App Store Connect API env vars (see [macOS release](/platforms/mac/release)). - -6. **Publish (npm)** - -- [ ] Confirm git status is clean; commit and push as needed. -- [ ] Confirm npm trusted publishing is configured for the `openclaw` package. -- [ ] Do not rely on an `NPM_TOKEN` secret for this workflow; the publish job uses GitHub OIDC trusted publishing. -- [ ] Push the matching git tag to trigger the preview run in `.github/workflows/openclaw-npm-release.yml`. -- [ ] Run `OpenClaw NPM Release` manually with the same tag to publish after `npm-release` environment approval. - - Stable tags publish to npm `latest`. - - Beta tags publish to npm `beta`. - - Fallback correction tags like `v2026.3.13-1` map to npm version `2026.3.13`. - - Both the preview run and the manual publish run reject tags that do not map back to `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date. - - If `openclaw@YYYY.M.D` is already published, a fallback correction tag is still useful for GitHub release and Docker recovery, but npm publish will not republish that version. -- [ ] Verify the registry: `npm view openclaw version`, `npm view openclaw dist-tags`, and `npx -y openclaw@X.Y.Z --version` (or `--help`). - -### Troubleshooting (notes from 2.0.0-beta2 release) - -- **npm pack/publish hangs or produces huge tarball**: the macOS app bundle in `dist/OpenClaw.app` (and release zips) get swept into the package. Fix by whitelisting publish contents via `package.json` `files` (include dist subdirs, docs, skills; exclude app bundles). Confirm with `npm pack --dry-run` that `dist/OpenClaw.app` is not listed. -- **npm auth web loop for dist-tags**: use legacy auth to get an OTP prompt: - - `NPM_CONFIG_AUTH_TYPE=legacy npm dist-tag add openclaw@X.Y.Z latest` -- **`npx` verification fails with `ECOMPROMISED: Lock compromised`**: retry with a fresh cache: - - `NPM_CONFIG_CACHE=/tmp/npm-cache-$(date +%s) npx -y openclaw@X.Y.Z --version` -- **Tag needs recovery after a late fix**: if the original stable tag is tied to an immutable GitHub release, mint a fallback correction tag like `vX.Y.Z-1` instead of trying to force-update `vX.Y.Z`. - - Keep the npm package version at `X.Y.Z`; the correction suffix is for the git tag and GitHub release only. - - Use this only as a last resort. For normal iteration, prefer beta tags and then cut a clean stable release. - -7. **GitHub release + appcast** - -- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`). - - Pushing the tag also triggers the npm release workflow. -- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `openclaw X.Y.Z`** (not just the tag); body should include the **full** changelog section for that version (Highlights + Changes + Fixes), inline (no bare links), and **must not repeat the title inside the body**. -- [ ] Attach artifacts: `npm pack` tarball (optional), `OpenClaw-X.Y.Z.zip`, and `OpenClaw-X.Y.Z.dSYM.zip` (if generated). -- [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main). -- [ ] From a clean temp directory (no `package.json`), run `npx -y openclaw@X.Y.Z send --help` to confirm install/CLI entrypoints work. -- [ ] Announce/share release notes. - -## Plugin publish scope (npm) - -We only publish **existing npm plugins** under the `@openclaw/*` scope. Bundled -plugins that are not on npm stay **disk-tree only** (still shipped in -`extensions/**`). - -Process to derive the list: - -1. `npm search @openclaw --json` and capture the package names. -2. Compare with `extensions/*/package.json` names. -3. Publish only the **intersection** (already on npm). - -Current npm plugin list (update as needed): - -- @openclaw/bluebubbles -- @openclaw/diagnostics-otel -- @openclaw/discord -- @openclaw/feishu -- @openclaw/lobster -- @openclaw/matrix -- @openclaw/msteams -- @openclaw/nextcloud-talk -- @openclaw/nostr -- @openclaw/voice-call -- @openclaw/zalo -- @openclaw/zalouser - -Release notes must also call out **new optional bundled plugins** that are **not -on by default** (example: `tlon`). +Maintainers use the private release docs in +[`openclaw/maintainers/release/README.md`](https://github.com/openclaw/maintainers/blob/main/release/README.md) +for the actual runbook. diff --git a/docs/start/hubs.md b/docs/start/hubs.md index cad1e41e114..9833b467378 100644 --- a/docs/start/hubs.md +++ b/docs/start/hubs.md @@ -157,7 +157,6 @@ Use these hubs to discover every page, including deep dives and reference docs t - [macOS permissions](/platforms/mac/permissions) - [macOS remote](/platforms/mac/remote) - [macOS signing](/platforms/mac/signing) -- [macOS release](/platforms/mac/release) - [macOS gateway (launchd)](/platforms/mac/bundled-gateway) - [macOS XPC](/platforms/mac/xpc) - [macOS skills](/platforms/mac/skills) @@ -190,5 +189,5 @@ Use these hubs to discover every page, including deep dives and reference docs t ## Testing + release - [Testing](/reference/test) -- [Release checklist](/reference/RELEASING) +- [Release policy](/reference/RELEASING) - [Device models](/reference/device-models) diff --git a/docs/zh-CN/AGENTS.md b/docs/zh-CN/AGENTS.md index cbf46cc310f..719a3576480 100644 --- a/docs/zh-CN/AGENTS.md +++ b/docs/zh-CN/AGENTS.md @@ -12,7 +12,7 @@ - 目标文档:`docs/zh-CN/**/*.md` - 术语表:`docs/.i18n/glossary.zh-CN.json` - 翻译记忆库:`docs/.i18n/zh-CN.tm.jsonl` -- 提示词规则:`scripts/docs-i18n/translator.go` +- 提示词规则:`scripts/docs-i18n/prompt.go` 常用运行方式: @@ -31,6 +31,8 @@ go run scripts/docs-i18n/main.go -mode segment docs/channels/matrix.md 注意事项: - doc 模式用于整页翻译;segment 模式用于小范围修补(依赖 TM)。 +- 新增技术术语、页面标题或短导航标签时,先更新 `docs/.i18n/glossary.zh-CN.json`,再跑 `doc` 模式;不要指望模型自行保留英文术语或固定译名。 +- `pnpm docs:check-i18n-glossary` 会检查变更过的英文文档标题和短内部链接标签是否已写入 glossary。 - 超大文件若超时,优先做**定点替换**或拆分后再跑。 - 翻译后检查中文引号、CJK-Latin 间距和术语一致性。 diff --git a/docs/zh-CN/platforms/mac/release.md b/docs/zh-CN/platforms/mac/release.md deleted file mode 100644 index d087a2bcb8c..00000000000 --- a/docs/zh-CN/platforms/mac/release.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -read_when: - - 制作或验证 OpenClaw macOS 发布版本 - - 更新 Sparkle appcast 或订阅源资源 -summary: OpenClaw macOS 发布清单(Sparkle 订阅源、打包、签名) -title: macOS 发布 -x-i18n: - generated_at: "2026-02-01T21:33:17Z" - model: claude-opus-4-5 - provider: pi - source_hash: 703c08c13793cd8c96bd4c31fb4904cdf4ffff35576e7ea48a362560d371cb30 - source_path: platforms/mac/release.md - workflow: 15 ---- - -# OpenClaw macOS 发布(Sparkle) - -本应用现已支持 Sparkle 自动更新。发布构建必须经过 Developer ID 签名、压缩,并发布包含签名的 appcast 条目。 - -## 前提条件 - -- 已安装 Developer ID Application 证书(示例:`Developer ID Application: ()`)。 -- 环境变量 `SPARKLE_PRIVATE_KEY_FILE` 已设置为 Sparkle ed25519 私钥路径(公钥已嵌入 Info.plist)。如果缺失,请检查 `~/.profile`。 -- 用于 `xcrun notarytool` 的公证凭据(钥匙串配置文件或 API 密钥),以实现通过 Gatekeeper 安全分发的 DMG/zip。 - - 我们使用名为 `openclaw-notary` 的钥匙串配置文件,由 shell 配置文件中的 App Store Connect API 密钥环境变量创建: - - `APP_STORE_CONNECT_API_KEY_P8`、`APP_STORE_CONNECT_KEY_ID`、`APP_STORE_CONNECT_ISSUER_ID` - - `echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > /tmp/openclaw-notary.p8` - - `xcrun notarytool store-credentials "openclaw-notary" --key /tmp/openclaw-notary.p8 --key-id "$APP_STORE_CONNECT_KEY_ID" --issuer "$APP_STORE_CONNECT_ISSUER_ID"` -- 已安装 `pnpm` 依赖(`pnpm install --config.node-linker=hoisted`)。 -- Sparkle 工具通过 SwiftPM 自动获取,位于 `apps/macos/.build/artifacts/sparkle/Sparkle/bin/`(`sign_update`、`generate_appcast` 等)。 - -## 构建与打包 - -注意事项: - -- `APP_BUILD` 映射到 `CFBundleVersion`/`sparkle:version`;保持纯数字且单调递增(不含 `-beta`),否则 Sparkle 会将其视为相同版本。 -- 默认为当前架构(`$(uname -m)`)。对于发布/通用构建,设置 `BUILD_ARCHS="arm64 x86_64"`(或 `BUILD_ARCHS=all`)。 -- 使用 `scripts/package-mac-dist.sh` 生成发布产物(zip + DMG + 公证)。使用 `scripts/package-mac-app.sh` 进行本地/开发打包。 - -```bash -# 从仓库根目录运行;设置发布 ID 以启用 Sparkle 订阅源。 -# APP_BUILD 必须为纯数字且单调递增,以便 Sparkle 正确比较。 -BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.1.27-beta.1 \ -APP_BUILD="$(git rev-list --count HEAD)" \ -BUILD_CONFIG=release \ -SIGN_IDENTITY="Developer ID Application: ()" \ -scripts/package-mac-app.sh - -# 打包用于分发的 zip(包含资源分支以支持 Sparkle 增量更新) -ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.1.27-beta.1.zip - -# 可选:同时构建适合用户使用的样式化 DMG(拖拽到 /Applications) -scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.1.27-beta.1.dmg - -# 推荐:构建 + 公证/装订 zip + DMG -# 首先,创建一次钥匙串配置文件: -# xcrun notarytool store-credentials "openclaw-notary" \ -# --apple-id "" --team-id "" --password "" -NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \ -BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.1.27-beta.1 \ -APP_BUILD="$(git rev-list --count HEAD)" \ -BUILD_CONFIG=release \ -SIGN_IDENTITY="Developer ID Application: ()" \ -scripts/package-mac-dist.sh - -# 可选:随发布一起提供 dSYM -ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.1.27-beta.1.dSYM.zip -``` - -## Appcast 条目 - -使用发布说明生成器,以便 Sparkle 渲染格式化的 HTML 说明: - -```bash -SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.1.27-beta.1.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml -``` - -从 `CHANGELOG.md`(通过 [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh))生成 HTML 发布说明,并将其嵌入 appcast 条目。 -发布时,将更新后的 `appcast.xml` 与发布资源(zip + dSYM)一起提交。 - -## 发布与验证 - -- 将 `OpenClaw-2026.1.27-beta.1.zip`(和 `OpenClaw-2026.1.27-beta.1.dSYM.zip`)上传到标签 `v2026.1.27-beta.1` 对应的 GitHub 发布。 -- 确保原始 appcast URL 与内置的订阅源匹配:`https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`。 -- 完整性检查: - - `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` 返回 200。 - - `curl -I ` 在资源上传后返回 200。 - - 在之前的公开构建版本上,从 About 选项卡运行"Check for Updates…",验证 Sparkle 能正常安装新构建。 - -完成定义:已签名的应用 + appcast 已发布,从旧版本的更新流程正常工作,且发布资源已附加到 GitHub 发布。 diff --git a/docs/zh-CN/reference/RELEASING.md b/docs/zh-CN/reference/RELEASING.md index 81b0832f11c..cb1d02f60e8 100644 --- a/docs/zh-CN/reference/RELEASING.md +++ b/docs/zh-CN/reference/RELEASING.md @@ -1,123 +1,48 @@ --- read_when: - - 发布新的 npm 版本 - - 发布新的 macOS 应用版本 - - 发布前验证元数据 -summary: npm + macOS 应用的逐步发布清单 + - 查找公开发布渠道的定义 + - 查找版本命名与发布节奏 +summary: 公开发布渠道、版本命名与发布节奏 +title: 发布策略 x-i18n: - generated_at: "2026-02-03T10:09:28Z" - model: claude-opus-4-5 + generated_at: "2026-03-15T19:23:11Z" + model: claude-opus-4-6 provider: pi - source_hash: 1a684bc26665966eb3c9c816d58d18eead008fd710041181ece38c21c5ff1c62 + source_hash: df332d3169de7099661725d9266955456e80fc3d3ff95cb7aaf9997a02f0baaf source_path: reference/RELEASING.md workflow: 15 --- -# 发布清单(npm + macOS) +# 发布策略 -从仓库根目录使用 `pnpm`(Node 22+)。在打标签/发布前保持工作树干净。 +OpenClaw 有三个公开发布渠道: -## 操作员触发 +- stable:带标签的正式发布,发布到 npm `latest` +- beta:预发布标签,发布到 npm `beta` +- dev:`main` 分支的最新提交 -当操作员说"release"时,立即执行此预检(除非遇到阻碍否则不要额外提问): +## 版本命名 -- 阅读本文档和 `docs/platforms/mac/release.md`。 -- 从 `~/.profile` 加载环境变量并确认 `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect 变量已设置(SPARKLE_PRIVATE_KEY_FILE 应位于 `~/.profile` 中)。 -- 如需要,使用 `~/Library/CloudStorage/Dropbox/Backup/Sparkle` 中的 Sparkle 密钥。 +- 正式发布版本号:`YYYY.M.D` + - Git 标签:`vYYYY.M.D` +- Beta 预发布版本号:`YYYY.M.D-beta.N` + - Git 标签:`vYYYY.M.D-beta.N` +- 月份和日期不补零 +- `latest` 表示当前 npm 正式发布版本 +- `beta` 表示当前 npm 预发布版本 +- Beta 版本可能会在 macOS 应用跟进之前发布 -1. **版本和元数据** +## 发布节奏 -- [ ] 更新 `package.json` 版本(例如 `2026.1.29`)。 -- [ ] 运行 `pnpm plugins:sync` 以对齐扩展包版本和变更日志。 -- [ ] 更新 CLI/版本字符串:[`src/cli/program.ts`](https://github.com/openclaw/openclaw/blob/main/src/cli/program.ts) 和 [`src/provider-web.ts`](https://github.com/openclaw/openclaw/blob/main/src/provider-web.ts) 中的 Baileys user agent。 -- [ ] 确认包元数据(name、description、repository、keywords、license)以及 `bin` 映射指向 [`openclaw.mjs`](https://github.com/openclaw/openclaw/blob/main/openclaw.mjs) 作为 `openclaw`。 -- [ ] 如果依赖项有变化,运行 `pnpm install` 确保 `pnpm-lock.yaml` 是最新的。 +- 发布遵循 beta 优先原则 +- 仅在最新的 beta 版本验证通过后才会发布正式版本 +- 详细的发布流程、审批、凭证和恢复说明仅限维护者查阅 -2. **构建和产物** +## 公开参考 -- [ ] 如果 A2UI 输入有变化,运行 `pnpm canvas:a2ui:bundle` 并提交更新后的 [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/openclaw/openclaw/blob/main/src/canvas-host/a2ui/a2ui.bundle.js)。 -- [ ] `pnpm run build`(重新生成 `dist/`)。 -- [ ] 验证 npm 包的 `files` 包含所有必需的 `dist/*` 文件夹(特别是用于 headless node + ACP CLI 的 `dist/node-host/**` 和 `dist/acp/**`)。 -- [ ] 确认 `dist/build-info.json` 存在并包含预期的 `commit` 哈希(CLI 横幅在 npm 安装时使用此信息)。 -- [ ] 可选:构建后运行 `npm pack --pack-destination /tmp`;检查 tarball 内容并保留以备 GitHub 发布使用(**不要**提交它)。 +- [`.github/workflows/openclaw-npm-release.yml`](https://github.com/openclaw/openclaw/blob/main/.github/workflows/openclaw-npm-release.yml) +- [`scripts/openclaw-npm-release-check.ts`](https://github.com/openclaw/openclaw/blob/main/scripts/openclaw-npm-release-check.ts) -3. **变更日志和文档** - -- [ ] 更新 `CHANGELOG.md`,添加面向用户的亮点(如果文件不存在则创建);按版本严格降序排列条目。 -- [ ] 确保 README 示例/标志与当前 CLI 行为匹配(特别是新命令或选项)。 - -4. **验证** - -- [ ] `pnpm build` -- [ ] `pnpm check` -- [ ] `pnpm test`(如需覆盖率输出则使用 `pnpm test:coverage`) -- [ ] `pnpm release:check`(验证 npm pack 内容) -- [ ] `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke`(Docker 安装冒烟测试,快速路径;发布前必需) - - 如果已知上一个 npm 发布版本有问题,为预安装步骤设置 `OPENCLAW_INSTALL_SMOKE_PREVIOUS=` 或 `OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS=1`。 -- [ ](可选)完整安装程序冒烟测试(添加非 root + CLI 覆盖):`pnpm test:install:smoke` -- [ ](可选)安装程序 E2E(Docker,运行 `curl -fsSL https://openclaw.ai/install.sh | bash`,新手引导,然后运行真实工具调用): - - `pnpm test:install:e2e:openai`(需要 `OPENAI_API_KEY`) - - `pnpm test:install:e2e:anthropic`(需要 `ANTHROPIC_API_KEY`) - - `pnpm test:install:e2e`(需要两个密钥;运行两个提供商) -- [ ](可选)如果你的更改影响发送/接收路径,抽查 Web Gateway 网关。 - -5. **macOS 应用(Sparkle)** - -- [ ] 构建并签名 macOS 应用,然后压缩以供分发。 -- [ ] 生成 Sparkle appcast(通过 [`scripts/make_appcast.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/make_appcast.sh) 生成 HTML 注释)并更新 `appcast.xml`。 -- [ ] 保留应用 zip(和可选的 dSYM zip)以便附加到 GitHub 发布。 -- [ ] 按照 [macOS 发布](/platforms/mac/release) 获取确切命令和所需环境变量。 - - `APP_BUILD` 必须是数字且单调递增(不带 `-beta`),以便 Sparkle 正确比较版本。 - - 如果进行公证,使用从 App Store Connect API 环境变量创建的 `openclaw-notary` 钥匙串配置文件(参见 [macOS 发布](/platforms/mac/release))。 - -6. **发布(npm)** - -- [ ] 确认 git 状态干净;根据需要提交并推送。 -- [ ] 如需要,`npm login`(验证 2FA)。 -- [ ] `npm publish --access public`(预发布版本使用 `--tag beta`)。 -- [ ] 验证注册表:`npm view openclaw version`、`npm view openclaw dist-tags` 和 `npx -y openclaw@X.Y.Z --version`(或 `--help`)。 - -### 故障排除(来自 2.0.0-beta2 发布的笔记) - -- **npm pack/publish 挂起或产生巨大 tarball**:`dist/OpenClaw.app` 中的 macOS 应用包(和发布 zip)被扫入包中。通过 `package.json` 的 `files` 白名单发布内容来修复(包含 dist 子目录、docs、skills;排除应用包)。用 `npm pack --dry-run` 确认 `dist/OpenClaw.app` 未列出。 -- **npm auth dist-tags 的 Web 循环**:使用旧版认证以获取 OTP 提示: - - `NPM_CONFIG_AUTH_TYPE=legacy npm dist-tag add openclaw@X.Y.Z latest` -- **`npx` 验证失败并显示 `ECOMPROMISED: Lock compromised`**:使用新缓存重试: - - `NPM_CONFIG_CACHE=/tmp/npm-cache-$(date +%s) npx -y openclaw@X.Y.Z --version` -- **延迟修复后需要重新指向标签**:强制更新并推送标签,然后确保 GitHub 发布资产仍然匹配: - - `git tag -f vX.Y.Z && git push -f origin vX.Y.Z` - -7. **GitHub 发布 + appcast** - -- [ ] 打标签并推送:`git tag vX.Y.Z && git push origin vX.Y.Z`(或 `git push --tags`)。 -- [ ] 为 `vX.Y.Z` 创建/刷新 GitHub 发布,**标题为 `openclaw X.Y.Z`**(不仅仅是标签);正文应包含该版本的**完整**变更日志部分(亮点 + 更改 + 修复),内联显示(无裸链接),且**不得在正文中重复标题**。 -- [ ] 附加产物:`npm pack` tarball(可选)、`OpenClaw-X.Y.Z.zip` 和 `OpenClaw-X.Y.Z.dSYM.zip`(如果生成)。 -- [ ] 提交更新后的 `appcast.xml` 并推送(Sparkle 从 main 获取源)。 -- [ ] 从干净的临时目录(无 `package.json`),运行 `npx -y openclaw@X.Y.Z send --help` 确认安装/CLI 入口点正常工作。 -- [ ] 宣布/分享发布说明。 - -## 插件发布范围(npm) - -我们只发布 `@openclaw/*` 范围下的**现有 npm 插件**。不在 npm 上的内置插件保持**仅磁盘树**(仍在 `extensions/**` 中发布)。 - -获取列表的流程: - -1. `npm search @openclaw --json` 并捕获包名。 -2. 与 `extensions/*/package.json` 名称比较。 -3. 只发布**交集**(已在 npm 上)。 - -当前 npm 插件列表(根据需要更新): - -- @openclaw/bluebubbles -- @openclaw/diagnostics-otel -- @openclaw/discord -- @openclaw/lobster -- @openclaw/matrix -- @openclaw/msteams -- @openclaw/nextcloud-talk -- @openclaw/nostr -- @openclaw/voice-call -- @openclaw/zalo -- @openclaw/zalouser - -发布说明还必须标注**默认未启用**的**新可选内置插件**(例如:`tlon`)。 +维护者使用 +[`openclaw/maintainers/release/README.md`](https://github.com/openclaw/maintainers/blob/main/release/README.md) +中的私有发布文档作为实际操作手册。 diff --git a/docs/zh-CN/start/hubs.md b/docs/zh-CN/start/hubs.md index a2e6260fdf2..b303102dcc0 100644 --- a/docs/zh-CN/start/hubs.md +++ b/docs/zh-CN/start/hubs.md @@ -1,20 +1,24 @@ --- read_when: - 你想要一份完整的文档地图 -summary: 链接到每篇 OpenClaw 文档的导航中心 +summary: 链接到所有 OpenClaw 文档的导航中心 title: 文档导航中心 x-i18n: - generated_at: "2026-02-04T17:55:29Z" - model: claude-opus-4-5 + generated_at: "2026-03-15T19:29:16Z" + model: claude-opus-4-6 provider: pi - source_hash: c4b4572b64d36c9690988b8f964b0712f551ee6491b18a493701a17d2d352cb4 + source_hash: e12e8b7881311fdaf08cd297392911dfa30dc46031a7038b6bb9011d166b1669 source_path: start/hubs.md workflow: 15 --- # 文档导航中心 -使用这些导航中心发现每一个页面,包括深入解析和参考文档——它们不一定出现在左侧导航栏中。 + +如果你是 OpenClaw 新用户,请从[入门指南](/start/getting-started)开始。 + + +使用这些导航中心发现每一个页面,包括深入解析和参考文档——它们可能不会出现在左侧导航栏中。 ## 从这里开始 @@ -75,7 +79,6 @@ x-i18n: - [模型提供商中心](/providers/models) - [WhatsApp](/channels/whatsapp) - [Telegram](/channels/telegram) -- [Telegram(grammY 注意事项)](/channels/grammy) - [Slack](/channels/slack) - [Discord](/channels/discord) - [Mattermost](/channels/mattermost)(插件) @@ -113,17 +116,18 @@ x-i18n: - [OpenProse](/prose) - [CLI 参考](/cli) - [Exec 工具](/tools/exec) +- [PDF 工具](/tools/pdf) - [提权模式](/tools/elevated) - [定时任务](/automation/cron-jobs) - [定时任务 vs 心跳](/automation/cron-vs-heartbeat) - [思考 + 详细输出](/tools/thinking) - [模型](/concepts/models) - [子智能体](/tools/subagents) -- [Agent send CLI](/tools/agent-send) +- [智能体发送 CLI](/tools/agent-send) - [终端界面](/web/tui) - [浏览器控制](/tools/browser) - [浏览器(Linux 故障排除)](/tools/browser-linux-troubleshooting) -- [轮询](/automation/poll) +- [投票](/automation/poll) ## 节点、媒体、语音 @@ -160,7 +164,6 @@ x-i18n: - [macOS 权限](/platforms/mac/permissions) - [macOS 远程](/platforms/mac/remote) - [macOS 签名](/platforms/mac/signing) -- [macOS 发布](/platforms/mac/release) - [macOS Gateway 网关 (launchd)](/platforms/mac/bundled-gateway) - [macOS XPC](/platforms/mac/xpc) - [macOS Skills](/platforms/mac/skills) @@ -183,8 +186,6 @@ x-i18n: ## 实验(探索性) - [新手引导配置协议](/experiments/onboarding-config-protocol) -- [定时任务加固笔记](/experiments/plans/cron-add-hardening) -- [群组策略加固笔记](/experiments/plans/group-policy-hardening) - [研究:记忆](/experiments/research/memory) - [模型配置探索](/experiments/proposals/model-config) @@ -195,5 +196,5 @@ x-i18n: ## 测试 + 发布 - [测试](/reference/test) -- [发布检查清单](/reference/RELEASING) +- [发布策略](/reference/RELEASING) - [设备型号](/reference/device-models) diff --git a/package.json b/package.json index 2d880e80fe7..a839cdd3ec1 100644 --- a/package.json +++ b/package.json @@ -231,7 +231,7 @@ "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", "check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", - "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links", + "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links", "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", "config:docs:check": "node --import tsx scripts/generate-config-doc-baseline.ts --check", @@ -246,6 +246,7 @@ "deadcode:ts-unused": "pnpm dlx ts-unused-exports tsconfig.json --ignoreTestFiles --exitWithCount", "dev": "node scripts/run-node.mjs", "docs:bin": "node scripts/build-docs-list.mjs", + "docs:check-i18n-glossary": "node scripts/check-docs-i18n-glossary.mjs", "docs:check-links": "node scripts/docs-link-audit.mjs", "docs:dev": "cd docs && mint dev", "docs:list": "node scripts/docs-list.js", diff --git a/scripts/check-docs-i18n-glossary.mjs b/scripts/check-docs-i18n-glossary.mjs new file mode 100644 index 00000000000..96f890bc4ff --- /dev/null +++ b/scripts/check-docs-i18n-glossary.mjs @@ -0,0 +1,237 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const ROOT = process.cwd(); +const GLOSSARY_PATH = path.join(ROOT, "docs", ".i18n", "glossary.zh-CN.json"); +const DOC_FILE_RE = /^docs\/(?!zh-CN\/).+\.(md|mdx)$/i; +const LIST_ITEM_LINK_RE = /^\s*(?:[-*]|\d+\.)\s+\[([^\]]+)\]\((\/[^)]+)\)/; +const MAX_TITLE_WORDS = 8; +const MAX_LABEL_WORDS = 6; +const MAX_TERM_LENGTH = 80; + +/** + * @typedef {{ + * file: string; + * line: number; + * kind: "title" | "link label"; + * term: string; + * }} TermMatch + */ + +function parseArgs(argv) { + /** @type {{ base: string; head: string }} */ + const args = { base: "", head: "" }; + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === "--base") { + args.base = argv[i + 1] ?? ""; + i += 1; + continue; + } + if (argv[i] === "--head") { + args.head = argv[i + 1] ?? ""; + i += 1; + } + } + return args; +} + +function runGit(args) { + return execFileSync("git", args, { + cwd: ROOT, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + }).trim(); +} + +function resolveBase(explicitBase) { + if (explicitBase) { + return explicitBase; + } + + const envBase = process.env.DOCS_I18N_GLOSSARY_BASE?.trim(); + if (envBase) { + return envBase; + } + + for (const candidate of ["origin/main", "fork/main", "main"]) { + try { + return runGit(["merge-base", candidate, "HEAD"]); + } catch { + // Try the next candidate. + } + } + + return ""; +} + +function listChangedDocs(base, head) { + const args = ["diff", "--name-only", "--diff-filter=ACMR", base]; + if (head) { + args.push(head); + } + args.push("--", "docs"); + + return runGit(args) + .split("\n") + .map((line) => line.trim()) + .filter((line) => DOC_FILE_RE.test(line)); +} + +function loadGlossarySources() { + const data = fs.readFileSync(GLOSSARY_PATH, "utf8"); + const entries = JSON.parse(data); + return new Set(entries.map((entry) => String(entry.source || "").trim()).filter(Boolean)); +} + +function containsLatin(text) { + return /[A-Za-z]/.test(text); +} + +function wordCount(text) { + return text.trim().split(/\s+/).filter(Boolean).length; +} + +function unquoteScalar(raw) { + const value = raw.trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1).trim(); + } + return value; +} + +function isGlossaryCandidate(term, maxWords) { + if (!term) { + return false; + } + if (!containsLatin(term)) { + return false; + } + if (term.includes("`")) { + return false; + } + if (term.length > MAX_TERM_LENGTH) { + return false; + } + return wordCount(term) <= maxWords; +} + +function readGitFile(base, relPath) { + try { + return runGit(["show", `${base}:${relPath}`]); + } catch { + return ""; + } +} + +/** + * @param {string} file + * @param {string} text + * @returns {Map} + */ +function extractTerms(file, text) { + /** @type {Map} */ + const terms = new Map(); + const lines = text.split("\n"); + + if (lines[0]?.trim() === "---") { + for (let index = 1; index < lines.length; index += 1) { + const line = lines[index]; + if (line.trim() === "---") { + break; + } + + const match = line.match(/^title:\s*(.+)\s*$/); + if (!match) { + continue; + } + + const title = unquoteScalar(match[1]); + if (isGlossaryCandidate(title, MAX_TITLE_WORDS)) { + terms.set(title, { file, line: index + 1, kind: "title", term: title }); + } + break; + } + } + + for (let index = 0; index < lines.length; index += 1) { + const match = lines[index].match(LIST_ITEM_LINK_RE); + if (!match) { + continue; + } + + const label = match[1].trim(); + if (!isGlossaryCandidate(label, MAX_LABEL_WORDS)) { + continue; + } + + if (!terms.has(label)) { + terms.set(label, { file, line: index + 1, kind: "link label", term: label }); + } + } + + return terms; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const base = resolveBase(args.base); + + if (!base) { + console.warn( + "docs:check-i18n-glossary: no merge base found; skipping glossary coverage check.", + ); + process.exit(0); + } + + const changedDocs = listChangedDocs(base, args.head); + if (changedDocs.length === 0) { + process.exit(0); + } + + const glossary = loadGlossarySources(); + /** @type {TermMatch[]} */ + const missing = []; + + for (const relPath of changedDocs) { + const absPath = path.join(ROOT, relPath); + if (!fs.existsSync(absPath)) { + continue; + } + + const currentTerms = extractTerms(relPath, fs.readFileSync(absPath, "utf8")); + const baseTerms = extractTerms(relPath, readGitFile(base, relPath)); + + for (const [term, match] of currentTerms) { + if (baseTerms.has(term)) { + continue; + } + if (glossary.has(term)) { + continue; + } + missing.push(match); + } + } + + if (missing.length === 0) { + process.exit(0); + } + + console.error("docs:check-i18n-glossary: missing zh-CN glossary entries for changed doc labels:"); + for (const match of missing) { + console.error(`- ${match.file}:${match.line} ${match.kind} "${match.term}"`); + } + console.error(""); + console.error( + "Add exact source terms to docs/.i18n/glossary.zh-CN.json before rerunning docs-i18n.", + ); + console.error(`Checked changed English docs relative to ${base}.`); + process.exit(1); +} + +main(); diff --git a/scripts/docs-i18n/prompt.go b/scripts/docs-i18n/prompt.go index 8ecf8688140..773dfd8fcfd 100644 --- a/scripts/docs-i18n/prompt.go +++ b/scripts/docs-i18n/prompt.go @@ -58,6 +58,11 @@ Rules: - Do not remove, reorder, or summarize content. - Use fluent, idiomatic technical Chinese; avoid slang or jokes. - Use neutral documentation tone; prefer “你/你的”, avoid “您/您的”. +- Glossary terms are mandatory. When a source term matches a glossary entry, use + the glossary target exactly, including headings, link labels, and short + UI-style labels. +- If a glossary target is identical to the source text, preserve that term in + English exactly as written. - Insert a space between Latin characters and CJK text (W3C CLREQ), e.g., “Gateway 网关”, “Skills 配置”. - Use Chinese quotation marks “ and ” for Chinese prose; keep ASCII quotes inside code spans/blocks or literal CLI/keys. - Keep product names in English: OpenClaw, Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal. @@ -90,6 +95,11 @@ Rules: - Do not remove, reorder, or summarize content. - Use fluent, idiomatic technical Japanese; avoid slang or jokes. - Use neutral documentation tone; avoid overly formal honorifics (e.g., avoid “〜でございます”). +- Glossary terms are mandatory. When a source term matches a glossary entry, use + the glossary target exactly, including headings, link labels, and short + UI-style labels. +- If a glossary target is identical to the source text, preserve that term in + English exactly as written. - Use Japanese quotation marks 「 and 」 for Japanese prose; keep ASCII quotes inside code spans/blocks or literal CLI/keys. - Do not add or remove spacing around Latin text just because it borders Japanese; keep spacing stable unless required by Japanese grammar. - Keep product names in English: OpenClaw, Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal. @@ -121,6 +131,11 @@ Rules: - Do not remove, reorder, or summarize content. - Use fluent, idiomatic technical language in the target language; avoid slang or jokes. - Use neutral documentation tone. +- Glossary terms are mandatory. When a source term matches a glossary entry, use + the glossary target exactly, including headings, link labels, and short + UI-style labels. +- If a glossary target is identical to the source text, preserve that term in + English exactly as written. - Keep product names in English: OpenClaw, Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal. - Keep these terms in English: Skills, local loopback, Tailscale. - Never output an empty response; if unsure, return the source text unchanged. @@ -135,7 +150,7 @@ func buildGlossaryPrompt(glossary []GlossaryEntry) string { return "" } var lines []string - lines = append(lines, "Preferred translations (use when natural):") + lines = append(lines, "Required terminology (use exactly when the source term matches):") for _, entry := range glossary { if entry.Source == "" || entry.Target == "" { continue From 594920f8cc9693e4b2f8bba2512711ab0f2f201f Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 16:19:27 -0400 Subject: [PATCH 017/943] Scripts: rebuild on extension and tsdown config changes (#47571) Merged via squash. Prepared head SHA: edd8ed825469128bbe85f86e2e1341f6c57687d7 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + README.md | 2 +- docs/help/debugging.md | 12 +- docs/start/setup.md | 3 +- scripts/run-node.d.mts | 2 + scripts/run-node.mjs | 127 ++++++++++-- scripts/watch-node.mjs | 28 +-- src/infra/run-node.test.ts | 362 +++++++++++++++++++++++++++++++++++ src/infra/watch-node.test.ts | 41 +++- 9 files changed, 539 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b85cd40bb3..0f77551f4f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ Docs: https://docs.openclaw.ai - CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc. - CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc. - Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. +- Gateway/watch mode: restart on bundled-plugin package and manifest metadata changes, rebuild `dist` for extension source and `tsdown.config.ts` changes, and still ignore extension docs. (#47571) thanks @gumadeiras. ## 2026.3.13 diff --git a/README.md b/README.md index d5a22313f27..fee53d83065 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ pnpm build pnpm openclaw onboard --install-daemon -# Dev loop (auto-reload on TS changes) +# Dev loop (auto-reload on source/config changes) pnpm gateway:watch ``` diff --git a/docs/help/debugging.md b/docs/help/debugging.md index 61539ec39a3..04fd150ef20 100644 --- a/docs/help/debugging.md +++ b/docs/help/debugging.md @@ -40,11 +40,17 @@ pnpm gateway:watch This maps to: ```bash -node --watch-path src --watch-path tsconfig.json --watch-path package.json --watch-preserve-output scripts/run-node.mjs gateway --force +node scripts/watch-node.mjs gateway --force ``` -Add any gateway CLI flags after `gateway:watch` and they will be passed through -on each restart. +The watcher restarts on build-relevant files under `src/`, extension source files, +extension `package.json` and `openclaw.plugin.json` metadata, `tsconfig.json`, +`package.json`, and `tsdown.config.ts`. Extension metadata changes restart the +gateway without forcing a `tsdown` rebuild; source and config changes still +rebuild `dist` first. + +Add any gateway CLI flags after `gateway:watch` and they will be passed through on +each restart. ## Dev profile + dev gateway (--dev) diff --git a/docs/start/setup.md b/docs/start/setup.md index 205f14d20a5..bf127cc0ad0 100644 --- a/docs/start/setup.md +++ b/docs/start/setup.md @@ -96,7 +96,8 @@ pnpm install pnpm gateway:watch ``` -`gateway:watch` runs the gateway in watch mode and reloads on TypeScript changes. +`gateway:watch` runs the gateway in watch mode and reloads on relevant source, +config, and bundled-plugin metadata changes. ### 2) Point the macOS app at your running Gateway diff --git a/scripts/run-node.d.mts b/scripts/run-node.d.mts index 1fc9a1437e0..e86c269d4d3 100644 --- a/scripts/run-node.d.mts +++ b/scripts/run-node.d.mts @@ -1,4 +1,6 @@ export const runNodeWatchedPaths: string[]; +export function isBuildRelevantRunNodePath(repoPath: string): boolean; +export function isRestartRelevantRunNodePath(repoPath: string): boolean; export function runNodeMain(params?: { spawn?: ( diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index 90e7c137209..0e3acd763b9 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -8,7 +8,63 @@ import { pathToFileURL } from "node:url"; const compiler = "tsdown"; const compilerArgs = ["exec", compiler, "--no-clean"]; -export const runNodeWatchedPaths = ["src", "tsconfig.json", "package.json"]; +const runNodeSourceRoots = ["src", "extensions"]; +const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.config.ts"]; +export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles]; +const extensionSourceFilePattern = /\.(?:[cm]?[jt]sx?)$/; +const extensionRestartMetadataFiles = new Set(["openclaw.plugin.json", "package.json"]); + +const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/"); + +const isIgnoredSourcePath = (relativePath) => { + const normalizedPath = normalizePath(relativePath); + return ( + normalizedPath.endsWith(".test.ts") || + normalizedPath.endsWith(".test.tsx") || + normalizedPath.endsWith("test-helpers.ts") + ); +}; + +const isBuildRelevantSourcePath = (relativePath) => { + const normalizedPath = normalizePath(relativePath); + return extensionSourceFilePattern.test(normalizedPath) && !isIgnoredSourcePath(normalizedPath); +}; + +export const isBuildRelevantRunNodePath = (repoPath) => { + const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, ""); + if (runNodeConfigFiles.includes(normalizedPath)) { + return true; + } + if (normalizedPath.startsWith("src/")) { + return !isIgnoredSourcePath(normalizedPath.slice("src/".length)); + } + if (normalizedPath.startsWith("extensions/")) { + return isBuildRelevantSourcePath(normalizedPath.slice("extensions/".length)); + } + return false; +}; + +const isRestartRelevantExtensionPath = (relativePath) => { + const normalizedPath = normalizePath(relativePath); + if (extensionRestartMetadataFiles.has(path.posix.basename(normalizedPath))) { + return true; + } + return isBuildRelevantSourcePath(normalizedPath); +}; + +export const isRestartRelevantRunNodePath = (repoPath) => { + const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, ""); + if (runNodeConfigFiles.includes(normalizedPath)) { + return true; + } + if (normalizedPath.startsWith("src/")) { + return !isIgnoredSourcePath(normalizedPath.slice("src/".length)); + } + if (normalizedPath.startsWith("extensions/")) { + return isRestartRelevantExtensionPath(normalizedPath.slice("extensions/".length)); + } + return false; +}; const statMtime = (filePath, fsImpl = fs) => { try { @@ -18,16 +74,12 @@ const statMtime = (filePath, fsImpl = fs) => { } }; -const isExcludedSource = (filePath, srcRoot) => { - const relativePath = path.relative(srcRoot, filePath); +const isExcludedSource = (filePath, sourceRoot, sourceRootName) => { + const relativePath = normalizePath(path.relative(sourceRoot, filePath)); if (relativePath.startsWith("..")) { return false; } - return ( - relativePath.endsWith(".test.ts") || - relativePath.endsWith(".test.tsx") || - relativePath.endsWith(`test-helpers.ts`) - ); + return !isBuildRelevantRunNodePath(path.posix.join(sourceRootName, relativePath)); }; const findLatestMtime = (dirPath, shouldSkip, deps) => { @@ -89,15 +141,39 @@ const resolveGitHead = (deps) => { return head || null; }; +const readGitStatus = (deps) => { + try { + const result = deps.spawnSync( + "git", + ["status", "--porcelain", "--untracked-files=normal", "--", ...runNodeWatchedPaths], + { + cwd: deps.cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }, + ); + if (result.status !== 0) { + return null; + } + return result.stdout ?? ""; + } catch { + return null; + } +}; + +const parseGitStatusPaths = (output) => + output + .split("\n") + .flatMap((line) => line.slice(3).split(" -> ")) + .map((entry) => normalizePath(entry.trim())) + .filter(Boolean); + const hasDirtySourceTree = (deps) => { - const output = runGit( - ["status", "--porcelain", "--untracked-files=normal", "--", ...runNodeWatchedPaths], - deps, - ); + const output = readGitStatus(deps); if (output === null) { return null; } - return output.length > 0; + return parseGitStatusPaths(output).some((repoPath) => isBuildRelevantRunNodePath(repoPath)); }; const readBuildStamp = (deps) => { @@ -119,12 +195,18 @@ const readBuildStamp = (deps) => { }; const hasSourceMtimeChanged = (stampMtime, deps) => { - const srcMtime = findLatestMtime( - deps.srcRoot, - (candidate) => isExcludedSource(candidate, deps.srcRoot), - deps, - ); - return srcMtime != null && srcMtime > stampMtime; + let latestSourceMtime = null; + for (const sourceRoot of deps.sourceRoots) { + const sourceMtime = findLatestMtime( + sourceRoot.path, + (candidate) => isExcludedSource(candidate, sourceRoot.path, sourceRoot.name), + deps, + ); + if (sourceMtime != null && (latestSourceMtime == null || sourceMtime > latestSourceMtime)) { + latestSourceMtime = sourceMtime; + } + } + return latestSourceMtime != null && latestSourceMtime > stampMtime; }; const shouldBuild = (deps) => { @@ -223,8 +305,11 @@ export async function runNodeMain(params = {}) { deps.distRoot = path.join(deps.cwd, "dist"); deps.distEntry = path.join(deps.distRoot, "/entry.js"); deps.buildStampPath = path.join(deps.distRoot, ".buildstamp"); - deps.srcRoot = path.join(deps.cwd, "src"); - deps.configFiles = [path.join(deps.cwd, "tsconfig.json"), path.join(deps.cwd, "package.json")]; + deps.sourceRoots = runNodeSourceRoots.map((sourceRoot) => ({ + name: sourceRoot, + path: path.join(deps.cwd, sourceRoot), + })); + deps.configFiles = runNodeConfigFiles.map((filePath) => path.join(deps.cwd, filePath)); if (!shouldBuild(deps)) { return await runOpenClaw(deps); diff --git a/scripts/watch-node.mjs b/scripts/watch-node.mjs index 891e07439a1..e4598ae79fe 100644 --- a/scripts/watch-node.mjs +++ b/scripts/watch-node.mjs @@ -1,26 +1,32 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; +import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; import chokidar from "chokidar"; -import { runNodeWatchedPaths } from "./run-node.mjs"; +import { isRestartRelevantRunNodePath, runNodeWatchedPaths } from "./run-node.mjs"; const WATCH_NODE_RUNNER = "scripts/run-node.mjs"; const WATCH_RESTART_SIGNAL = "SIGTERM"; const buildRunnerArgs = (args) => [WATCH_NODE_RUNNER, ...args]; -const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/"); +const normalizePath = (filePath) => + String(filePath ?? "") + .replaceAll("\\", "/") + .replace(/^\.\/+/, ""); -const isIgnoredWatchPath = (filePath) => { - const normalizedPath = normalizePath(filePath); - return ( - normalizedPath.endsWith(".test.ts") || - normalizedPath.endsWith(".test.tsx") || - normalizedPath.endsWith("test-helpers.ts") - ); +const resolveRepoPath = (filePath, cwd) => { + const rawPath = String(filePath ?? ""); + if (path.isAbsolute(rawPath)) { + return normalizePath(path.relative(cwd, rawPath)); + } + return normalizePath(rawPath); }; +const isIgnoredWatchPath = (filePath, cwd) => + !isRestartRelevantRunNodePath(resolveRepoPath(filePath, cwd)); + export async function runWatchMain(params = {}) { const deps = { spawn: params.spawn ?? spawn, @@ -52,7 +58,7 @@ export async function runWatchMain(params = {}) { const watcher = deps.createWatcher(deps.watchPaths, { ignoreInitial: true, - ignored: (watchPath) => isIgnoredWatchPath(watchPath), + ignored: (watchPath) => isIgnoredWatchPath(watchPath, deps.cwd), }); const settle = (code) => { @@ -89,7 +95,7 @@ export async function runWatchMain(params = {}) { }; const requestRestart = (changedPath) => { - if (shuttingDown || isIgnoredWatchPath(changedPath)) { + if (shuttingDown || isIgnoredWatchPath(changedPath, deps.cwd)) { return; } if (!watchProcess) { diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 0b8cf1090bc..7ba07fdaf2d 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -24,6 +24,12 @@ function createExitedProcess(code: number | null, signal: string | null = null) }; } +function expectedBuildSpawn(platform: NodeJS.Platform = process.platform) { + return platform === "win32" + ? ["cmd.exe", "/d", "/s", "/c", "pnpm", "exec", "tsdown", "--no-clean"] + : ["pnpm", "exec", "tsdown", "--no-clean"]; +} + describe("run-node script", () => { it.runIf(process.platform !== "win32")( "preserves control-ui assets by building with tsdown --no-clean", @@ -161,4 +167,360 @@ describe("run-node script", () => { expect(exitCode).toBe(23); }); }); + + it("rebuilds when extension sources are newer than the build stamp", async () => { + await withTempDir(async (tmp) => { + const extensionPath = path.join(tmp, "extensions", "demo", "src", "index.ts"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + await fs.mkdir(path.dirname(extensionPath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile(extensionPath, "export const extensionValue = 1;\n", "utf-8"); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + const newTime = new Date("2026-03-13T12:00:01.000Z"); + await fs.utimes(tsconfigPath, stampTime, stampTime); + await fs.utimes(packageJsonPath, stampTime, stampTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + await fs.utimes(extensionPath, newTime, newTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = () => ({ status: 1, stdout: "" }); + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([ + expectedBuildSpawn(), + [process.execPath, "openclaw.mjs", "status"], + ]); + }); + }); + + it("skips rebuilding when extension package metadata is newer than the build stamp", async () => { + await withTempDir(async (tmp) => { + const packagePath = path.join(tmp, "extensions", "demo", "package.json"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await fs.mkdir(path.dirname(packagePath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile( + packagePath, + '{"name":"demo","openclaw":{"extensions":["./index.ts"]}}\n', + "utf-8", + ); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + + const oldTime = new Date("2026-03-13T10:00:00.000Z"); + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + const newTime = new Date("2026-03-13T12:00:01.000Z"); + await fs.utimes(tsconfigPath, oldTime, oldTime); + await fs.utimes(packageJsonPath, oldTime, oldTime); + await fs.utimes(tsdownConfigPath, oldTime, oldTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + await fs.utimes(packagePath, newTime, newTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = () => ({ status: 1, stdout: "" }); + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + }); + }); + + it("skips rebuilding for dirty non-source files under extensions", async () => { + await withTempDir(async (tmp) => { + const srcPath = path.join(tmp, "src", "index.ts"); + const readmePath = path.join(tmp, "extensions", "demo", "README.md"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await fs.mkdir(path.dirname(srcPath), { recursive: true }); + await fs.mkdir(path.dirname(readmePath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); + await fs.writeFile(readmePath, "# demo\n", "utf-8"); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + await fs.utimes(srcPath, stampTime, stampTime); + await fs.utimes(readmePath, stampTime, stampTime); + await fs.utimes(tsconfigPath, stampTime, stampTime); + await fs.utimes(packageJsonPath, stampTime, stampTime); + await fs.utimes(tsdownConfigPath, stampTime, stampTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = (cmd: string, args: string[]) => { + if (cmd === "git" && args[0] === "rev-parse") { + return { status: 0, stdout: "abc123\n" }; + } + if (cmd === "git" && args[0] === "status") { + return { status: 0, stdout: " M extensions/demo/README.md\n" }; + } + return { status: 1, stdout: "" }; + }; + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + }); + }); + + it("skips rebuilding for dirty extension manifests that only affect runtime reload", async () => { + await withTempDir(async (tmp) => { + const srcPath = path.join(tmp, "src", "index.ts"); + const manifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await fs.mkdir(path.dirname(srcPath), { recursive: true }); + await fs.mkdir(path.dirname(manifestPath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); + await fs.writeFile(manifestPath, '{"id":"demo"}\n', "utf-8"); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + await fs.utimes(srcPath, stampTime, stampTime); + await fs.utimes(manifestPath, stampTime, stampTime); + await fs.utimes(tsconfigPath, stampTime, stampTime); + await fs.utimes(packageJsonPath, stampTime, stampTime); + await fs.utimes(tsdownConfigPath, stampTime, stampTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = (cmd: string, args: string[]) => { + if (cmd === "git" && args[0] === "rev-parse") { + return { status: 0, stdout: "abc123\n" }; + } + if (cmd === "git" && args[0] === "status") { + return { status: 0, stdout: " M extensions/demo/openclaw.plugin.json\n" }; + } + return { status: 1, stdout: "" }; + }; + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + }); + }); + + it("skips rebuilding when only non-source extension files are newer than the build stamp", async () => { + await withTempDir(async (tmp) => { + const srcPath = path.join(tmp, "src", "index.ts"); + const readmePath = path.join(tmp, "extensions", "demo", "README.md"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await fs.mkdir(path.dirname(srcPath), { recursive: true }); + await fs.mkdir(path.dirname(readmePath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); + await fs.writeFile(readmePath, "# demo\n", "utf-8"); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + + const oldTime = new Date("2026-03-13T10:00:00.000Z"); + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + const newTime = new Date("2026-03-13T12:00:01.000Z"); + await fs.utimes(srcPath, oldTime, oldTime); + await fs.utimes(tsconfigPath, oldTime, oldTime); + await fs.utimes(packageJsonPath, oldTime, oldTime); + await fs.utimes(tsdownConfigPath, oldTime, oldTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + await fs.utimes(readmePath, newTime, newTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = () => ({ status: 1, stdout: "" }); + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + }); + }); + + it("rebuilds when tsdown config is newer than the build stamp", async () => { + await withTempDir(async (tmp) => { + const srcPath = path.join(tmp, "src", "index.ts"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await fs.mkdir(path.dirname(srcPath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + + const oldTime = new Date("2026-03-13T10:00:00.000Z"); + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + const newTime = new Date("2026-03-13T12:00:01.000Z"); + await fs.utimes(srcPath, oldTime, oldTime); + await fs.utimes(tsconfigPath, oldTime, oldTime); + await fs.utimes(packageJsonPath, oldTime, oldTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + await fs.utimes(tsdownConfigPath, newTime, newTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = (cmd: string, args: string[]) => { + if (cmd === "git" && args[0] === "rev-parse") { + return { status: 0, stdout: "abc123\n" }; + } + if (cmd === "git" && args[0] === "status") { + return { status: 0, stdout: "" }; + } + return { status: 1, stdout: "" }; + }; + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([ + expectedBuildSpawn(), + [process.execPath, "openclaw.mjs", "status"], + ]); + }); + }); }); diff --git a/src/infra/watch-node.test.ts b/src/infra/watch-node.test.ts index 89ec4b79ef2..8fa92bae1df 100644 --- a/src/infra/watch-node.test.ts +++ b/src/infra/watch-node.test.ts @@ -44,10 +44,17 @@ describe("watch-node script", () => { { ignoreInitial: boolean; ignored: (watchPath: string) => boolean }, ]; expect(watchPaths).toEqual(runNodeWatchedPaths); + expect(watchPaths).toContain("extensions"); + expect(watchPaths).toContain("tsdown.config.ts"); expect(watchOptions.ignoreInitial).toBe(true); expect(watchOptions.ignored("src/infra/watch-node.test.ts")).toBe(true); expect(watchOptions.ignored("src/infra/watch-node.test.tsx")).toBe(true); expect(watchOptions.ignored("src/infra/watch-node-test-helpers.ts")).toBe(true); + expect(watchOptions.ignored("extensions/voice-call/README.md")).toBe(true); + expect(watchOptions.ignored("extensions/voice-call/openclaw.plugin.json")).toBe(false); + expect(watchOptions.ignored("extensions/voice-call/package.json")).toBe(false); + expect(watchOptions.ignored("extensions/voice-call/index.ts")).toBe(false); + expect(watchOptions.ignored("extensions/voice-call/src/runtime.ts")).toBe(false); expect(watchOptions.ignored("src/infra/watch-node.ts")).toBe(false); expect(watchOptions.ignored("tsconfig.json")).toBe(false); @@ -120,9 +127,24 @@ describe("watch-node script", () => { }), }); const childB = Object.assign(new EventEmitter(), { + kill: vi.fn(function () { + queueMicrotask(() => childB.emit("exit", 0, null)); + }), + }); + const childC = Object.assign(new EventEmitter(), { + kill: vi.fn(function () { + queueMicrotask(() => childC.emit("exit", 0, null)); + }), + }); + const childD = Object.assign(new EventEmitter(), { kill: vi.fn(() => {}), }); - const spawn = vi.fn().mockReturnValueOnce(childA).mockReturnValueOnce(childB); + const spawn = vi + .fn() + .mockReturnValueOnce(childA) + .mockReturnValueOnce(childB) + .mockReturnValueOnce(childC) + .mockReturnValueOnce(childD); const watcher = Object.assign(new EventEmitter(), { close: vi.fn(async () => {}), }); @@ -151,11 +173,26 @@ describe("watch-node script", () => { expect(spawn).toHaveBeenCalledTimes(1); expect(childA.kill).not.toHaveBeenCalled(); - watcher.emit("change", "src/infra/watch-node.ts"); + watcher.emit("change", "extensions/voice-call/README.md"); + await new Promise((resolve) => setImmediate(resolve)); + expect(spawn).toHaveBeenCalledTimes(1); + expect(childA.kill).not.toHaveBeenCalled(); + + watcher.emit("change", "extensions/voice-call/openclaw.plugin.json"); await new Promise((resolve) => setImmediate(resolve)); expect(childA.kill).toHaveBeenCalledWith("SIGTERM"); expect(spawn).toHaveBeenCalledTimes(2); + watcher.emit("change", "extensions/voice-call/package.json"); + await new Promise((resolve) => setImmediate(resolve)); + expect(childB.kill).toHaveBeenCalledWith("SIGTERM"); + expect(spawn).toHaveBeenCalledTimes(3); + + watcher.emit("change", "src/infra/watch-node.ts"); + await new Promise((resolve) => setImmediate(resolve)); + expect(childC.kill).toHaveBeenCalledWith("SIGTERM"); + expect(spawn).toHaveBeenCalledTimes(4); + fakeProcess.emit("SIGINT"); const exitCode = await runPromise; expect(exitCode).toBe(130); From 07f890fa45bc782fab594fdfc77505aca6ea4c4b Mon Sep 17 00:00:00 2001 From: Ted Li Date: Sun, 15 Mar 2026 13:31:30 -0700 Subject: [PATCH 018/943] fix(release): block oversized npm packs that regress low-memory startup (#46850) * fix(release): guard npm pack size regressions * fix(release): fail closed when npm omits pack size --- scripts/release-check.ts | 59 ++++++++++++++++++++++++++++++++++++-- test/release-check.test.ts | 32 +++++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 6f621cef2d5..34d37634d6f 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -15,7 +15,7 @@ import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./s export { collectBundledExtensionManifestErrors } from "./lib/bundled-extension-manifest.ts"; type PackFile = { path: string }; -type PackResult = { files?: PackFile[] }; +type PackResult = { files?: PackFile[]; filename?: string; unpackedSize?: number }; const requiredPathGroups = [ ["dist/index.js", "dist/index.mjs"], @@ -112,6 +112,10 @@ const requiredPathGroups = [ "dist/build-info.json", ]; const forbiddenPrefixes = ["dist/OpenClaw.app/"]; +// 2026.3.12 ballooned to ~213.6 MiB unpacked and correlated with low-memory +// startup/doctor OOM reports. Keep enough headroom for the current pack while +// failing fast if duplicate/shim content sneaks back into the release artifact. +const npmPackUnpackedSizeBudgetBytes = 160 * 1024 * 1024; const appcastPath = resolve("appcast.xml"); const laneBuildMin = 1_000_000_000; const laneFloorAdoptionDateKey = 20260227; @@ -228,6 +232,50 @@ export function collectForbiddenPackPaths(paths: Iterable): string[] { .toSorted(); } +function formatMiB(bytes: number): string { + return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; +} + +function resolvePackResultLabel(entry: PackResult, index: number): string { + return entry.filename?.trim() || `pack result #${index + 1}`; +} + +function formatPackUnpackedSizeBudgetError(params: { + label: string; + unpackedSize: number; +}): string { + return [ + `${params.label} unpackedSize ${params.unpackedSize} bytes (${formatMiB(params.unpackedSize)}) exceeds budget ${npmPackUnpackedSizeBudgetBytes} bytes (${formatMiB(npmPackUnpackedSizeBudgetBytes)}).`, + "Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.", + ].join(" "); +} + +export function collectPackUnpackedSizeErrors(results: Iterable): string[] { + const entries = Array.from(results); + const errors: string[] = []; + let checkedCount = 0; + + for (const [index, entry] of entries.entries()) { + if (typeof entry.unpackedSize !== "number" || !Number.isFinite(entry.unpackedSize)) { + continue; + } + checkedCount += 1; + if (entry.unpackedSize <= npmPackUnpackedSizeBudgetBytes) { + continue; + } + const label = resolvePackResultLabel(entry, index); + errors.push(formatPackUnpackedSizeBudgetError({ label, unpackedSize: entry.unpackedSize })); + } + + if (entries.length > 0 && checkedCount === 0) { + errors.push( + "npm pack --dry-run produced no unpackedSize data; pack size budget was not verified.", + ); + } + + return errors; +} + function checkPluginVersions() { const rootPackagePath = resolve("package.json"); const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")) as PackageJson; @@ -433,8 +481,9 @@ function main() { }) .toSorted(); const forbidden = collectForbiddenPackPaths(paths); + const sizeErrors = collectPackUnpackedSizeErrors(results); - if (missing.length > 0 || forbidden.length > 0) { + if (missing.length > 0 || forbidden.length > 0 || sizeErrors.length > 0) { if (missing.length > 0) { console.error("release-check: missing files in npm pack:"); for (const path of missing) { @@ -447,6 +496,12 @@ function main() { console.error(` - ${path}`); } } + if (sizeErrors.length > 0) { + console.error("release-check: npm pack unpacked size budget exceeded:"); + for (const error of sizeErrors) { + console.error(` - ${error}`); + } + } process.exit(1); } diff --git a/test/release-check.test.ts b/test/release-check.test.ts index a399407aa98..5f0bcf65192 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -4,12 +4,17 @@ import { collectBundledExtensionManifestErrors, collectBundledExtensionRootDependencyGapErrors, collectForbiddenPackPaths, + collectPackUnpackedSizeErrors, } from "../scripts/release-check.ts"; function makeItem(shortVersion: string, sparkleVersion: string): string { return `${shortVersion}${shortVersion}${sparkleVersion}`; } +function makePackResult(filename: string, unpackedSize: number) { + return { filename, unpackedSize }; +} + describe("collectAppcastSparkleVersionErrors", () => { it("accepts legacy 9-digit calver builds before lane-floor cutover", () => { const xml = `${makeItem("2026.2.26", "202602260")}`; @@ -163,3 +168,30 @@ describe("collectForbiddenPackPaths", () => { ).toEqual(["extensions/tlon/node_modules/.bin/tlon", "node_modules/.bin/openclaw"]); }); }); + +describe("collectPackUnpackedSizeErrors", () => { + it("accepts pack results within the unpacked size budget", () => { + expect( + collectPackUnpackedSizeErrors([makePackResult("openclaw-2026.3.14.tgz", 120_354_302)]), + ).toEqual([]); + }); + + it("flags oversized pack results that risk low-memory startup failures", () => { + expect( + collectPackUnpackedSizeErrors([makePackResult("openclaw-2026.3.12.tgz", 224_002_564)]), + ).toEqual([ + "openclaw-2026.3.12.tgz unpackedSize 224002564 bytes (213.6 MiB) exceeds budget 167772160 bytes (160.0 MiB). Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.", + ]); + }); + + it("fails closed when npm pack output omits unpackedSize for every result", () => { + expect( + collectPackUnpackedSizeErrors([ + { filename: "openclaw-2026.3.14.tgz" }, + { filename: "openclaw-extra.tgz", unpackedSize: Number.NaN }, + ]), + ).toEqual([ + "npm pack --dry-run produced no unpackedSize data; pack size budget was not verified.", + ]); + }); +}); From 85dd0ab2f8472932a886734cb2520e1f091e0d52 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 13:33:37 -0700 Subject: [PATCH 019/943] Plugins: reserve context engine ownership (#47595) * Plugins: reserve context engine ownership * Update src/context-engine/registry.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/context-engine/context-engine.test.ts | 24 +++++++-- src/context-engine/registry.ts | 38 ++++++++++--- src/plugins/loader.test.ts | 65 +++++++++++++++++++++++ src/plugins/registry.ts | 22 +++++++- 4 files changed, 137 insertions(+), 12 deletions(-) diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index cd0f2f50439..5cdc03a7114 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -231,18 +231,36 @@ describe("Registry tests", () => { expect(Array.isArray(ids)).toBe(true); }); - it("registering the same id overwrites the previous factory", () => { + it("registering the same id with the same owner refreshes the factory", () => { const factory1 = () => new MockContextEngine(); const factory2 = () => new MockContextEngine(); - registerContextEngine("reg-overwrite", factory1); + expect(registerContextEngine("reg-overwrite", factory1, { owner: "owner-a" })).toEqual({ + ok: true, + }); expect(getContextEngineFactory("reg-overwrite")).toBe(factory1); - registerContextEngine("reg-overwrite", factory2); + expect(registerContextEngine("reg-overwrite", factory2, { owner: "owner-a" })).toEqual({ + ok: true, + }); expect(getContextEngineFactory("reg-overwrite")).toBe(factory2); expect(getContextEngineFactory("reg-overwrite")).not.toBe(factory1); }); + it("rejects context engine registrations from a different owner", () => { + const factory1 = () => new MockContextEngine(); + const factory2 = () => new MockContextEngine(); + + expect(registerContextEngine("reg-owner-guard", factory1, { owner: "owner-a" })).toEqual({ + ok: true, + }); + expect(registerContextEngine("reg-owner-guard", factory2, { owner: "owner-b" })).toEqual({ + ok: false, + existingOwner: "owner-a", + }); + expect(getContextEngineFactory("reg-owner-guard")).toBe(factory1); + }); + it("shares registered engines across duplicate module copies", async () => { const registryUrl = new URL("./registry.ts", import.meta.url).href; const suffix = Date.now().toString(36); diff --git a/src/context-engine/registry.ts b/src/context-engine/registry.ts index d73266c62de..ba04da7c51d 100644 --- a/src/context-engine/registry.ts +++ b/src/context-engine/registry.ts @@ -7,6 +7,7 @@ import type { ContextEngine } from "./types.js"; * Supports async creation for engines that need DB connections etc. */ export type ContextEngineFactory = () => ContextEngine | Promise; +export type ContextEngineRegistrationResult = { ok: true } | { ok: false; existingOwner: string }; // --------------------------------------------------------------------------- // Registry (module-level singleton) @@ -15,7 +16,13 @@ export type ContextEngineFactory = () => ContextEngine | Promise; const CONTEXT_ENGINE_REGISTRY_STATE = Symbol.for("openclaw.contextEngineRegistryState"); type ContextEngineRegistryState = { - engines: Map; + engines: Map< + string, + { + factory: ContextEngineFactory; + owner: string; + } + >; }; // Keep context-engine registrations process-global so duplicated dist chunks @@ -26,7 +33,7 @@ function getContextEngineRegistryState(): ContextEngineRegistryState { }; if (!globalState[CONTEXT_ENGINE_REGISTRY_STATE]) { globalState[CONTEXT_ENGINE_REGISTRY_STATE] = { - engines: new Map(), + engines: new Map(), }; } return globalState[CONTEXT_ENGINE_REGISTRY_STATE]; @@ -35,15 +42,30 @@ function getContextEngineRegistryState(): ContextEngineRegistryState { /** * Register a context engine implementation under the given id. */ -export function registerContextEngine(id: string, factory: ContextEngineFactory): void { - getContextEngineRegistryState().engines.set(id, factory); +export function registerContextEngine( + id: string, + factory: ContextEngineFactory, + opts?: { owner?: string }, +): ContextEngineRegistrationResult { + const rawOwner = opts?.owner?.trim(); + if (opts?.owner !== undefined && !rawOwner) { + throw new Error(`registerContextEngine: owner must be a non-empty string, got ${JSON.stringify(opts.owner)}`); + } + const owner = rawOwner || "core"; + const registry = getContextEngineRegistryState().engines; + const existing = registry.get(id); + if (existing && existing.owner !== owner) { + return { ok: false, existingOwner: existing.owner }; + } + registry.set(id, { factory, owner }); + return { ok: true }; } /** * Return the factory for a registered engine, or undefined. */ export function getContextEngineFactory(id: string): ContextEngineFactory | undefined { - return getContextEngineRegistryState().engines.get(id); + return getContextEngineRegistryState().engines.get(id)?.factory; } /** @@ -73,13 +95,13 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise { ).toBe(true); }); + it("rejects plugin context engine ids reserved by core", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "context-engine-core-collision", + filename: "context-engine-core-collision.cjs", + body: `module.exports = { id: "context-engine-core-collision", register(api) { + api.registerContextEngine("legacy", () => ({})); +} };`, + }); + + const registry = loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: ["context-engine-core-collision"], + }, + }); + + expect( + registry.diagnostics.some( + (diag) => + diag.level === "error" && + diag.pluginId === "context-engine-core-collision" && + diag.message === "context engine id reserved by core: legacy", + ), + ).toBe(true); + }); + + it("rejects duplicate plugin context engine ids", () => { + useNoBundledPlugins(); + const first = writePlugin({ + id: "context-engine-owner-a", + filename: "context-engine-owner-a.cjs", + body: `module.exports = { id: "context-engine-owner-a", register(api) { + api.registerContextEngine("shared-context-engine-loader-test", () => ({})); +} };`, + }); + const second = writePlugin({ + id: "context-engine-owner-b", + filename: "context-engine-owner-b.cjs", + body: `module.exports = { id: "context-engine-owner-b", register(api) { + api.registerContextEngine("shared-context-engine-loader-test", () => ({})); +} };`, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [first.file, second.file] }, + allow: ["context-engine-owner-a", "context-engine-owner-b"], + }, + }, + }); + + expect( + registry.diagnostics.some( + (diag) => + diag.level === "error" && + diag.pluginId === "context-engine-owner-b" && + diag.message === + "context engine already registered: shared-context-engine-loader-test (plugin:context-engine-owner-a)", + ), + ).toBe(true); + }); + it("requires plugin CLI registrars to declare explicit command roots", () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index c1c63cc96cb..952c8d7744b 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -15,6 +15,7 @@ import { normalizePluginHttpPath } from "./http-path.js"; import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; import { normalizeRegisteredProvider } from "./provider-validation.js"; import type { PluginRuntime } from "./runtime/types.js"; +import { defaultSlotIdForKey } from "./slots.js"; import { isPluginHookName, isPromptInjectionHookName, @@ -653,7 +654,26 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerCli: (registrar, opts) => registerCli(record, registrar, opts), registerService: (service) => registerService(record, service), registerCommand: (command) => registerCommand(record, command), - registerContextEngine: (id, factory) => registerContextEngine(id, factory), + registerContextEngine: (id, factory) => { + if (id === defaultSlotIdForKey("contextEngine")) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `context engine id reserved by core: ${id}`, + }); + return; + } + const result = registerContextEngine(id, factory, { owner: `plugin:${record.id}` }); + if (!result.ok) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `context engine already registered: ${id} (${result.existingOwner})`, + }); + } + }, resolvePath: (input: string) => resolveUserPath(input), on: (hookName, handler, opts) => registerTypedHook(record, hookName, handler, opts, params.hookPolicy), From 4fb01603090c9697fa18d82a7c2d99597dfd14c7 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 20:44:03 +0000 Subject: [PATCH 020/943] Gateway: sync runtime post-build artifacts --- CHANGELOG.md | 1 + package.json | 6 +- scripts/copy-bundled-plugin-metadata.mjs | 89 +++++--- scripts/copy-plugin-sdk-root-alias.mjs | 20 +- scripts/run-node.mjs | 20 ++ scripts/runtime-postbuild-shared.mjs | 26 +++ scripts/runtime-postbuild.mjs | 12 ++ src/infra/run-node.test.ts | 245 ++++++++++++++++++++++- 8 files changed, 376 insertions(+), 43 deletions(-) create mode 100644 scripts/runtime-postbuild-shared.mjs create mode 100644 scripts/runtime-postbuild.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f77551f4f8..72069e7b364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai - CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc. - Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. - Gateway/watch mode: restart on bundled-plugin package and manifest metadata changes, rebuild `dist` for extension source and `tsdown.config.ts` changes, and still ignore extension docs. (#47571) thanks @gumadeiras. +- Gateway/watch mode: recreate bundled plugin runtime metadata after clean or stale `dist` states, so `pnpm gateway:watch` no longer fails on missing `dist/extensions/*/openclaw.plugin.json` manifests after a rebuild. Thanks @gumadeiras. ## 2026.3.13 diff --git a/package.json b/package.json index a839cdd3ec1..d8f1e530d9b 100644 --- a/package.json +++ b/package.json @@ -225,10 +225,10 @@ "android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n ai.openclaw.app/.MainActivity", "android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest", "android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 vitest run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts", - "build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && node scripts/copy-bundled-plugin-metadata.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", - "build:docker": "node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && node scripts/copy-bundled-plugin-metadata.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", + "build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", + "build:docker": "node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json || true", - "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts", + "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", "check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links", diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs index 40d8baa5299..a137872d421 100644 --- a/scripts/copy-bundled-plugin-metadata.mjs +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -1,11 +1,7 @@ -#!/usr/bin/env node - import fs from "node:fs"; import path from "node:path"; - -const repoRoot = process.cwd(); -const extensionsRoot = path.join(repoRoot, "extensions"); -const distExtensionsRoot = path.join(repoRoot, "dist", "extensions"); +import { pathToFileURL } from "node:url"; +import { removeFileIfExists, writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs"; function rewritePackageExtensions(entries) { if (!Array.isArray(entries)) { @@ -21,37 +17,66 @@ function rewritePackageExtensions(entries) { }); } -for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) { - if (!dirent.isDirectory()) { - continue; +export function copyBundledPluginMetadata(params = {}) { + const repoRoot = params.cwd ?? process.cwd(); + const extensionsRoot = path.join(repoRoot, "extensions"); + const distExtensionsRoot = path.join(repoRoot, "dist", "extensions"); + if (!fs.existsSync(extensionsRoot)) { + return; } - const pluginDir = path.join(extensionsRoot, dirent.name); - const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); - if (!fs.existsSync(manifestPath)) { - continue; + const sourcePluginDirs = new Set(); + + for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) { + if (!dirent.isDirectory()) { + continue; + } + sourcePluginDirs.add(dirent.name); + + const pluginDir = path.join(extensionsRoot, dirent.name); + const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); + const distPluginDir = path.join(distExtensionsRoot, dirent.name); + const distManifestPath = path.join(distPluginDir, "openclaw.plugin.json"); + const distPackageJsonPath = path.join(distPluginDir, "package.json"); + if (!fs.existsSync(manifestPath)) { + removeFileIfExists(distManifestPath); + removeFileIfExists(distPackageJsonPath); + continue; + } + + writeTextFileIfChanged(distManifestPath, fs.readFileSync(manifestPath, "utf8")); + + const packageJsonPath = path.join(pluginDir, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + removeFileIfExists(distPackageJsonPath); + continue; + } + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + if (packageJson.openclaw && "extensions" in packageJson.openclaw) { + packageJson.openclaw = { + ...packageJson.openclaw, + extensions: rewritePackageExtensions(packageJson.openclaw.extensions), + }; + } + + writeTextFileIfChanged(distPackageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); } - const distPluginDir = path.join(distExtensionsRoot, dirent.name); - fs.mkdirSync(distPluginDir, { recursive: true }); - fs.copyFileSync(manifestPath, path.join(distPluginDir, "openclaw.plugin.json")); - - const packageJsonPath = path.join(pluginDir, "package.json"); - if (!fs.existsSync(packageJsonPath)) { - continue; + if (!fs.existsSync(distExtensionsRoot)) { + return; } - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); - if (packageJson.openclaw && "extensions" in packageJson.openclaw) { - packageJson.openclaw = { - ...packageJson.openclaw, - extensions: rewritePackageExtensions(packageJson.openclaw.extensions), - }; + for (const dirent of fs.readdirSync(distExtensionsRoot, { withFileTypes: true })) { + if (!dirent.isDirectory() || sourcePluginDirs.has(dirent.name)) { + continue; + } + const distPluginDir = path.join(distExtensionsRoot, dirent.name); + removeFileIfExists(path.join(distPluginDir, "openclaw.plugin.json")); + removeFileIfExists(path.join(distPluginDir, "package.json")); } - - fs.writeFileSync( - path.join(distPluginDir, "package.json"), - `${JSON.stringify(packageJson, null, 2)}\n`, - "utf8", - ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + copyBundledPluginMetadata(); } diff --git a/scripts/copy-plugin-sdk-root-alias.mjs b/scripts/copy-plugin-sdk-root-alias.mjs index b1bf80b6312..982a5fa9eeb 100644 --- a/scripts/copy-plugin-sdk-root-alias.mjs +++ b/scripts/copy-plugin-sdk-root-alias.mjs @@ -1,10 +1,16 @@ -#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs"; -import { copyFileSync, mkdirSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +export function copyPluginSdkRootAlias(params = {}) { + const cwd = params.cwd ?? process.cwd(); + const source = resolve(cwd, "src/plugin-sdk/root-alias.cjs"); + const target = resolve(cwd, "dist/plugin-sdk/root-alias.cjs"); -const source = resolve("src/plugin-sdk/root-alias.cjs"); -const target = resolve("dist/plugin-sdk/root-alias.cjs"); + writeTextFileIfChanged(target, readFileSync(source, "utf8")); +} -mkdirSync(dirname(target), { recursive: true }); -copyFileSync(source, target); +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + copyPluginSdkRootAlias(); +} diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index 0e3acd763b9..56a63805e70 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -4,6 +4,7 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; +import { runRuntimePostBuild } from "./runtime-postbuild.mjs"; const compiler = "tsdown"; const compilerArgs = ["exec", compiler, "--no-clean"]; @@ -275,6 +276,19 @@ const runOpenClaw = async (deps) => { return res.exitCode ?? 1; }; +const syncRuntimeArtifacts = (deps) => { + try { + runRuntimePostBuild({ cwd: deps.cwd }); + } catch (error) { + logRunner( + `Failed to write runtime build artifacts: ${error?.message ?? "unknown error"}`, + deps, + ); + return false; + } + return true; +}; + const writeBuildStamp = (deps) => { try { deps.fs.mkdirSync(deps.distRoot, { recursive: true }); @@ -312,6 +326,9 @@ export async function runNodeMain(params = {}) { deps.configFiles = runNodeConfigFiles.map((filePath) => path.join(deps.cwd, filePath)); if (!shouldBuild(deps)) { + if (!syncRuntimeArtifacts(deps)) { + return 1; + } return await runOpenClaw(deps); } @@ -334,6 +351,9 @@ export async function runNodeMain(params = {}) { if (buildRes.exitCode !== 0 && buildRes.exitCode !== null) { return buildRes.exitCode; } + if (!syncRuntimeArtifacts(deps)) { + return 1; + } writeBuildStamp(deps); return await runOpenClaw(deps); } diff --git a/scripts/runtime-postbuild-shared.mjs b/scripts/runtime-postbuild-shared.mjs new file mode 100644 index 00000000000..34ca6bb7930 --- /dev/null +++ b/scripts/runtime-postbuild-shared.mjs @@ -0,0 +1,26 @@ +import fs from "node:fs"; +import { dirname } from "node:path"; + +export function writeTextFileIfChanged(filePath, contents) { + const next = String(contents); + try { + const current = fs.readFileSync(filePath, "utf8"); + if (current === next) { + return false; + } + } catch { + // Write the file when it does not exist or cannot be read. + } + fs.mkdirSync(dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, next, "utf8"); + return true; +} + +export function removeFileIfExists(filePath) { + try { + fs.rmSync(filePath, { force: true }); + return true; + } catch { + return false; + } +} diff --git a/scripts/runtime-postbuild.mjs b/scripts/runtime-postbuild.mjs new file mode 100644 index 00000000000..884ba7af036 --- /dev/null +++ b/scripts/runtime-postbuild.mjs @@ -0,0 +1,12 @@ +import { pathToFileURL } from "node:url"; +import { copyBundledPluginMetadata } from "./copy-bundled-plugin-metadata.mjs"; +import { copyPluginSdkRootAlias } from "./copy-plugin-sdk-root-alias.mjs"; + +export function runRuntimePostBuild(params = {}) { + copyPluginSdkRootAlias(params); + copyBundledPluginMetadata(params); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + runRuntimePostBuild(); +} diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 7ba07fdaf2d..59ac7cd0666 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -24,6 +24,15 @@ function createExitedProcess(code: number | null, signal: string | null = null) }; } +async function writeRuntimePostBuildScaffold(tmp: string): Promise { + const pluginSdkAliasPath = path.join(tmp, "src", "plugin-sdk", "root-alias.cjs"); + await fs.mkdir(path.dirname(pluginSdkAliasPath), { recursive: true }); + await fs.mkdir(path.join(tmp, "extensions"), { recursive: true }); + await fs.writeFile(pluginSdkAliasPath, "module.exports = {};\n", "utf-8"); + const baselineTime = new Date("2026-03-13T09:00:00.000Z"); + await fs.utimes(pluginSdkAliasPath, baselineTime, baselineTime); +} + function expectedBuildSpawn(platform: NodeJS.Platform = process.platform) { return platform === "win32" ? ["cmd.exe", "/d", "/s", "/c", "pnpm", "exec", "tsdown", "--no-clean"] @@ -38,6 +47,7 @@ describe("run-node script", () => { const argsPath = path.join(tmp, ".pnpm-args.txt"); const indexPath = path.join(tmp, "dist", "control-ui", "index.html"); + await writeRuntimePostBuildScaffold(tmp); await fs.mkdir(path.dirname(indexPath), { recursive: true }); await fs.writeFile(indexPath, "sentinel\n", "utf-8"); @@ -84,6 +94,73 @@ describe("run-node script", () => { }, ); + it("copies bundled plugin metadata after rebuilding from a clean dist", async () => { + await withTempDir(async (tmp) => { + const extensionManifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json"); + const extensionPackagePath = path.join(tmp, "extensions", "demo", "package.json"); + + await writeRuntimePostBuildScaffold(tmp); + await fs.mkdir(path.dirname(extensionManifestPath), { recursive: true }); + await fs.writeFile( + extensionManifestPath, + '{"id":"demo","configSchema":{"type":"object"}}\n', + "utf-8", + ); + await fs.writeFile( + extensionPackagePath, + JSON.stringify( + { + name: "demo", + openclaw: { + extensions: ["./src/index.ts", "./nested/entry.mts"], + }, + }, + null, + 2, + ) + "\n", + "utf-8", + ); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_FORCE_BUILD: "1", + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([ + expectedBuildSpawn(), + [process.execPath, "openclaw.mjs", "status"], + ]); + + await expect( + fs.readFile(path.join(tmp, "dist", "plugin-sdk", "root-alias.cjs"), "utf-8"), + ).resolves.toContain("module.exports = {};"); + await expect( + fs.readFile(path.join(tmp, "dist", "extensions", "demo", "openclaw.plugin.json"), "utf-8"), + ).resolves.toContain('"id":"demo"'); + await expect( + fs.readFile(path.join(tmp, "dist", "extensions", "demo", "package.json"), "utf-8"), + ).resolves.toContain( + '"extensions": [\n "./src/index.js",\n "./nested/entry.js"\n ]', + ); + }); + }); + it("skips rebuilding when dist is current and the source tree is clean", async () => { await withTempDir(async (tmp) => { const srcPath = path.join(tmp, "src", "index.ts"); @@ -91,6 +168,7 @@ describe("run-node script", () => { const buildStampPath = path.join(tmp, "dist", ".buildstamp"); const tsconfigPath = path.join(tmp, "tsconfig.json"); const packageJsonPath = path.join(tmp, "package.json"); + await writeRuntimePostBuildScaffold(tmp); await fs.mkdir(path.dirname(srcPath), { recursive: true }); await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); @@ -175,6 +253,7 @@ describe("run-node script", () => { const buildStampPath = path.join(tmp, "dist", ".buildstamp"); const tsconfigPath = path.join(tmp, "tsconfig.json"); const packageJsonPath = path.join(tmp, "package.json"); + await writeRuntimePostBuildScaffold(tmp); await fs.mkdir(path.dirname(extensionPath), { recursive: true }); await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); await fs.writeFile(extensionPath, "export const extensionValue = 1;\n", "utf-8"); @@ -222,14 +301,20 @@ describe("run-node script", () => { it("skips rebuilding when extension package metadata is newer than the build stamp", async () => { await withTempDir(async (tmp) => { + const manifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json"); const packagePath = path.join(tmp, "extensions", "demo", "package.json"); + const distPackagePath = path.join(tmp, "dist", "extensions", "demo", "package.json"); const distEntryPath = path.join(tmp, "dist", "entry.js"); const buildStampPath = path.join(tmp, "dist", ".buildstamp"); const tsconfigPath = path.join(tmp, "tsconfig.json"); const packageJsonPath = path.join(tmp, "package.json"); const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await writeRuntimePostBuildScaffold(tmp); + await fs.mkdir(path.dirname(manifestPath), { recursive: true }); await fs.mkdir(path.dirname(packagePath), { recursive: true }); await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.mkdir(path.dirname(distPackagePath), { recursive: true }); + await fs.writeFile(manifestPath, '{"id":"demo","configSchema":{"type":"object"}}\n', "utf-8"); await fs.writeFile( packagePath, '{"name":"demo","openclaw":{"extensions":["./index.ts"]}}\n', @@ -239,11 +324,17 @@ describe("run-node script", () => { await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile( + distPackagePath, + '{"name":"demo","openclaw":{"extensions":["./stale.js"]}}\n', + "utf-8", + ); await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); const oldTime = new Date("2026-03-13T10:00:00.000Z"); const stampTime = new Date("2026-03-13T12:00:00.000Z"); const newTime = new Date("2026-03-13T12:00:01.000Z"); + await fs.utimes(manifestPath, oldTime, oldTime); await fs.utimes(tsconfigPath, oldTime, oldTime); await fs.utimes(packageJsonPath, oldTime, oldTime); await fs.utimes(tsdownConfigPath, oldTime, oldTime); @@ -274,6 +365,7 @@ describe("run-node script", () => { expect(exitCode).toBe(0); expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + await expect(fs.readFile(distPackagePath, "utf-8")).resolves.toContain('"./index.js"'); }); }); @@ -286,6 +378,7 @@ describe("run-node script", () => { const tsconfigPath = path.join(tmp, "tsconfig.json"); const packageJsonPath = path.join(tmp, "package.json"); const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await writeRuntimePostBuildScaffold(tmp); await fs.mkdir(path.dirname(srcPath), { recursive: true }); await fs.mkdir(path.dirname(readmePath), { recursive: true }); await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); @@ -344,20 +437,28 @@ describe("run-node script", () => { await withTempDir(async (tmp) => { const srcPath = path.join(tmp, "src", "index.ts"); const manifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json"); + const distManifestPath = path.join(tmp, "dist", "extensions", "demo", "openclaw.plugin.json"); const distEntryPath = path.join(tmp, "dist", "entry.js"); const buildStampPath = path.join(tmp, "dist", ".buildstamp"); const tsconfigPath = path.join(tmp, "tsconfig.json"); const packageJsonPath = path.join(tmp, "package.json"); const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await writeRuntimePostBuildScaffold(tmp); await fs.mkdir(path.dirname(srcPath), { recursive: true }); await fs.mkdir(path.dirname(manifestPath), { recursive: true }); await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.mkdir(path.dirname(distManifestPath), { recursive: true }); await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); - await fs.writeFile(manifestPath, '{"id":"demo"}\n', "utf-8"); + await fs.writeFile(manifestPath, '{"id":"demo","configSchema":{"type":"object"}}\n', "utf-8"); await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile( + distManifestPath, + '{"id":"stale","configSchema":{"type":"object"}}\n', + "utf-8", + ); await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); const stampTime = new Date("2026-03-13T12:00:00.000Z"); @@ -400,6 +501,146 @@ describe("run-node script", () => { expect(exitCode).toBe(0); expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + await expect(fs.readFile(distManifestPath, "utf-8")).resolves.toContain('"id":"demo"'); + }); + }); + + it("repairs missing bundled plugin metadata without rerunning tsdown", async () => { + await withTempDir(async (tmp) => { + const srcPath = path.join(tmp, "src", "index.ts"); + const manifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json"); + const distManifestPath = path.join(tmp, "dist", "extensions", "demo", "openclaw.plugin.json"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await writeRuntimePostBuildScaffold(tmp); + await fs.mkdir(path.dirname(srcPath), { recursive: true }); + await fs.mkdir(path.dirname(manifestPath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); + await fs.writeFile(manifestPath, '{"id":"demo","configSchema":{"type":"object"}}\n', "utf-8"); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + await fs.utimes(srcPath, stampTime, stampTime); + await fs.utimes(manifestPath, stampTime, stampTime); + await fs.utimes(tsconfigPath, stampTime, stampTime); + await fs.utimes(packageJsonPath, stampTime, stampTime); + await fs.utimes(tsdownConfigPath, stampTime, stampTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = (cmd: string, args: string[]) => { + if (cmd === "git" && args[0] === "rev-parse") { + return { status: 0, stdout: "abc123\n" }; + } + if (cmd === "git" && args[0] === "status") { + return { status: 0, stdout: "" }; + } + return { status: 1, stdout: "" }; + }; + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + await expect(fs.readFile(distManifestPath, "utf-8")).resolves.toContain('"id":"demo"'); + }); + }); + + it("removes stale bundled plugin metadata when the source manifest is gone", async () => { + await withTempDir(async (tmp) => { + const srcPath = path.join(tmp, "src", "index.ts"); + const extensionDir = path.join(tmp, "extensions", "demo"); + const distManifestPath = path.join(tmp, "dist", "extensions", "demo", "openclaw.plugin.json"); + const distPackagePath = path.join(tmp, "dist", "extensions", "demo", "package.json"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await writeRuntimePostBuildScaffold(tmp); + await fs.mkdir(path.dirname(srcPath), { recursive: true }); + await fs.mkdir(extensionDir, { recursive: true }); + await fs.mkdir(path.dirname(distManifestPath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + await fs.writeFile( + distManifestPath, + '{"id":"stale","configSchema":{"type":"object"}}\n', + "utf-8", + ); + await fs.writeFile(distPackagePath, '{"name":"stale"}\n', "utf-8"); + + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + await fs.utimes(srcPath, stampTime, stampTime); + await fs.utimes(tsconfigPath, stampTime, stampTime); + await fs.utimes(packageJsonPath, stampTime, stampTime); + await fs.utimes(tsdownConfigPath, stampTime, stampTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = (cmd: string, args: string[]) => { + if (cmd === "git" && args[0] === "rev-parse") { + return { status: 0, stdout: "abc123\n" }; + } + if (cmd === "git" && args[0] === "status") { + return { status: 0, stdout: "" }; + } + return { status: 1, stdout: "" }; + }; + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + await expect(fs.access(distManifestPath)).rejects.toThrow(); + await expect(fs.access(distPackagePath)).rejects.toThrow(); }); }); @@ -412,6 +653,7 @@ describe("run-node script", () => { const tsconfigPath = path.join(tmp, "tsconfig.json"); const packageJsonPath = path.join(tmp, "package.json"); const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await writeRuntimePostBuildScaffold(tmp); await fs.mkdir(path.dirname(srcPath), { recursive: true }); await fs.mkdir(path.dirname(readmePath), { recursive: true }); await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); @@ -468,6 +710,7 @@ describe("run-node script", () => { const tsconfigPath = path.join(tmp, "tsconfig.json"); const packageJsonPath = path.join(tmp, "package.json"); const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await writeRuntimePostBuildScaffold(tmp); await fs.mkdir(path.dirname(srcPath), { recursive: true }); await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); From 7931f06c001d900c3b6a46328129d57caa9422a7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 13:49:48 -0700 Subject: [PATCH 021/943] Plugins: harden context engine ownership --- CHANGELOG.md | 1 + src/context-engine/context-engine.test.ts | 93 ++++++++++++++++++++--- src/context-engine/legacy.ts | 6 +- src/context-engine/registry.ts | 4 +- src/plugins/registry.ts | 6 +- 5 files changed, 95 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72069e7b364..15521744304 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ Docs: https://docs.openclaw.ai - Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. - Gateway/watch mode: restart on bundled-plugin package and manifest metadata changes, rebuild `dist` for extension source and `tsdown.config.ts` changes, and still ignore extension docs. (#47571) thanks @gumadeiras. - Gateway/watch mode: recreate bundled plugin runtime metadata after clean or stale `dist` states, so `pnpm gateway:watch` no longer fails on missing `dist/extensions/*/openclaw.plugin.json` manifests after a rebuild. Thanks @gumadeiras. +- Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc. ## 2026.3.13 diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 5cdc03a7114..703ee88bf57 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -8,10 +8,12 @@ import { compactEmbeddedPiSessionDirect } from "../agents/pi-embedded-runner/com import { LegacyContextEngine, registerLegacyContextEngine } from "./legacy.js"; import { registerContextEngine, + registerContextEngineForOwner, getContextEngineFactory, listContextEngineIds, resolveContextEngine, } from "./registry.js"; +import type { ContextEngineFactory, ContextEngineRegistrationResult } from "./registry.js"; import type { ContextEngine, ContextEngineInfo, @@ -235,14 +237,18 @@ describe("Registry tests", () => { const factory1 = () => new MockContextEngine(); const factory2 = () => new MockContextEngine(); - expect(registerContextEngine("reg-overwrite", factory1, { owner: "owner-a" })).toEqual({ - ok: true, - }); + expect( + registerContextEngineForOwner("reg-overwrite", factory1, "owner-a", { + allowSameOwnerRefresh: true, + }), + ).toEqual({ ok: true }); expect(getContextEngineFactory("reg-overwrite")).toBe(factory1); - expect(registerContextEngine("reg-overwrite", factory2, { owner: "owner-a" })).toEqual({ - ok: true, - }); + expect( + registerContextEngineForOwner("reg-overwrite", factory2, "owner-a", { + allowSameOwnerRefresh: true, + }), + ).toEqual({ ok: true }); expect(getContextEngineFactory("reg-overwrite")).toBe(factory2); expect(getContextEngineFactory("reg-overwrite")).not.toBe(factory1); }); @@ -251,16 +257,56 @@ describe("Registry tests", () => { const factory1 = () => new MockContextEngine(); const factory2 = () => new MockContextEngine(); - expect(registerContextEngine("reg-owner-guard", factory1, { owner: "owner-a" })).toEqual({ - ok: true, - }); - expect(registerContextEngine("reg-owner-guard", factory2, { owner: "owner-b" })).toEqual({ + expect( + registerContextEngineForOwner("reg-owner-guard", factory1, "owner-a", { + allowSameOwnerRefresh: true, + }), + ).toEqual({ ok: true }); + expect(registerContextEngineForOwner("reg-owner-guard", factory2, "owner-b")).toEqual({ ok: false, existingOwner: "owner-a", }); expect(getContextEngineFactory("reg-owner-guard")).toBe(factory1); }); + it("public registerContextEngine cannot spoof owner or refresh existing ids", () => { + const ownedFactory = () => new MockContextEngine(); + expect( + registerContextEngineForOwner("public-owner-guard", ownedFactory, "owner-a", { + allowSameOwnerRefresh: true, + }), + ).toEqual({ ok: true }); + + const spoofAttempt = ( + registerContextEngine as unknown as ( + id: string, + factory: ContextEngineFactory, + opts?: { owner?: string }, + ) => ContextEngineRegistrationResult + )("public-owner-guard", () => new MockContextEngine(), { owner: "owner-a" }); + + expect(spoofAttempt).toEqual({ + ok: false, + existingOwner: "owner-a", + }); + expect(getContextEngineFactory("public-owner-guard")).toBe(ownedFactory); + }); + + it("public registerContextEngine reserves the default legacy id", () => { + const legacyAttempt = ( + registerContextEngine as unknown as ( + id: string, + factory: ContextEngineFactory, + opts?: { owner?: string }, + ) => ContextEngineRegistrationResult + )("legacy", () => new MockContextEngine(), { owner: "core" }); + + expect(legacyAttempt).toEqual({ + ok: false, + existingOwner: "core", + }); + }); + it("shares registered engines across duplicate module copies", async () => { const registryUrl = new URL("./registry.ts", import.meta.url).href; const suffix = Date.now().toString(36); @@ -492,6 +538,33 @@ describe("Bundle chunk isolation (#40096)", () => { expect(getContextEngineFactory(sdkEngineId)).toBeDefined(); }); + it("plugin-sdk registerContextEngine cannot spoof privileged ownership", async () => { + const ts = Date.now().toString(36); + const engineId = `sdk-spoof-guard-${ts}`; + const ownedFactory = () => new MockContextEngine(); + expect( + registerContextEngineForOwner(engineId, ownedFactory, "plugin:owner-a", { + allowSameOwnerRefresh: true, + }), + ).toEqual({ ok: true }); + + const sdkUrl = new URL("../plugin-sdk/index.ts", import.meta.url).href; + const sdk = await import(/* @vite-ignore */ `${sdkUrl}?sdk-spoof-${ts}`); + const spoofAttempt = ( + sdk.registerContextEngine as unknown as ( + id: string, + factory: ContextEngineFactory, + opts?: { owner?: string }, + ) => ContextEngineRegistrationResult + )(engineId, () => new MockContextEngine(), { owner: "plugin:owner-a" }); + + expect(spoofAttempt).toEqual({ + ok: false, + existingOwner: "plugin:owner-a", + }); + expect(getContextEngineFactory(engineId)).toBe(ownedFactory); + }); + it("concurrent registration from multiple chunks does not lose entries", async () => { const ts = Date.now().toString(36); const registryUrl = new URL("./registry.ts", import.meta.url).href; diff --git a/src/context-engine/legacy.ts b/src/context-engine/legacy.ts index 0485a4feae4..3080e9aba0b 100644 --- a/src/context-engine/legacy.ts +++ b/src/context-engine/legacy.ts @@ -1,5 +1,5 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import { registerContextEngine } from "./registry.js"; +import { registerContextEngineForOwner } from "./registry.js"; import type { ContextEngine, ContextEngineInfo, @@ -124,5 +124,7 @@ export class LegacyContextEngine implements ContextEngine { } export function registerLegacyContextEngine(): void { - registerContextEngine("legacy", () => new LegacyContextEngine()); + registerContextEngineForOwner("legacy", () => new LegacyContextEngine(), "core", { + allowSameOwnerRefresh: true, + }); } diff --git a/src/context-engine/registry.ts b/src/context-engine/registry.ts index 9a186609f20..1701877790a 100644 --- a/src/context-engine/registry.ts +++ b/src/context-engine/registry.ts @@ -48,7 +48,9 @@ function getContextEngineRegistryState(): ContextEngineRegistryState { function requireContextEngineOwner(owner: string): string { const normalizedOwner = owner.trim(); if (!normalizedOwner) { - throw new Error(`registerContextEngineForOwner: owner must be a non-empty string, got ${JSON.stringify(owner)}`); + throw new Error( + `registerContextEngineForOwner: owner must be a non-empty string, got ${JSON.stringify(owner)}`, + ); } return normalizedOwner; } diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 952c8d7744b..fe978d6a346 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -2,7 +2,7 @@ import path from "node:path"; import type { AnyAgentTool } from "../agents/tools/common.js"; import type { ChannelDock } from "../channels/dock.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; -import { registerContextEngine } from "../context-engine/registry.js"; +import { registerContextEngineForOwner } from "../context-engine/registry.js"; import type { GatewayRequestHandler, GatewayRequestHandlers, @@ -664,7 +664,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); return; } - const result = registerContextEngine(id, factory, { owner: `plugin:${record.id}` }); + const result = registerContextEngineForOwner(id, factory, `plugin:${record.id}`, { + allowSameOwnerRefresh: true, + }); if (!result.ok) { pushDiagnostic({ level: "error", From 47fd8558cd5a3299d27c5cd254482a8bfa476642 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Sun, 15 Mar 2026 23:00:30 +0200 Subject: [PATCH 022/943] fix(plugins): fix bundled plugin roots and skill assets (#47601) * fix(acpx): resolve bundled plugin root correctly * fix(plugins): copy bundled plugin skill assets * fix(plugins): tolerate missing bundled skill paths --- CHANGELOG.md | 1 + extensions/acpx/src/config.test.ts | 31 ++++ extensions/acpx/src/config.ts | 23 ++- scripts/copy-bundled-plugin-metadata.d.mts | 3 + scripts/copy-bundled-plugin-metadata.mjs | 53 ++++++- .../copy-bundled-plugin-metadata.test.ts | 144 ++++++++++++++++++ 6 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 scripts/copy-bundled-plugin-metadata.d.mts create mode 100644 src/plugins/copy-bundled-plugin-metadata.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 15521744304..2b4546d49d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146) - Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts. - Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`. +- ACP/acpx: resolve the bundled plugin root from the actual plugin directory so plugin-local installs stay under `dist/extensions/acpx` instead of escaping to `dist/extensions` and failing runtime setup. - Gateway/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. Thanks @vincentkoc. - Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc. - Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc. diff --git a/extensions/acpx/src/config.test.ts b/extensions/acpx/src/config.test.ts index 45be08e3edf..5a19d6f43e8 100644 --- a/extensions/acpx/src/config.test.ts +++ b/extensions/acpx/src/config.test.ts @@ -1,13 +1,44 @@ +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; import { ACPX_BUNDLED_BIN, ACPX_PINNED_VERSION, createAcpxPluginConfigSchema, + resolveAcpxPluginRoot, resolveAcpxPluginConfig, } from "./config.js"; describe("acpx plugin config parsing", () => { + it("resolves source-layout plugin root from a file under src", () => { + const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-source-")); + try { + fs.mkdirSync(path.join(pluginRoot, "src"), { recursive: true }); + fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8"); + fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8"); + + const moduleUrl = pathToFileURL(path.join(pluginRoot, "src", "config.ts")).href; + expect(resolveAcpxPluginRoot(moduleUrl)).toBe(pluginRoot); + } finally { + fs.rmSync(pluginRoot, { recursive: true, force: true }); + } + }); + + it("resolves bundled-layout plugin root from the dist entry file", () => { + const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-dist-")); + try { + fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8"); + fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8"); + + const moduleUrl = pathToFileURL(path.join(pluginRoot, "index.js")).href; + expect(resolveAcpxPluginRoot(moduleUrl)).toBe(pluginRoot); + } finally { + fs.rmSync(pluginRoot, { recursive: true, force: true }); + } + }); + it("resolves bundled acpx with pinned version by default", () => { const resolved = resolveAcpxPluginConfig({ rawConfig: { diff --git a/extensions/acpx/src/config.ts b/extensions/acpx/src/config.ts index ef0207a1365..d6bfb3a44db 100644 --- a/extensions/acpx/src/config.ts +++ b/extensions/acpx/src/config.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/acpx"; @@ -11,7 +12,27 @@ export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_PO export const ACPX_PINNED_VERSION = "0.1.16"; export const ACPX_VERSION_ANY = "any"; const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx"; -export const ACPX_PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): string { + let cursor = path.dirname(fileURLToPath(moduleUrl)); + for (let i = 0; i < 3; i += 1) { + // Bundled entries live at the plugin root while source files still live under src/. + if ( + fs.existsSync(path.join(cursor, "openclaw.plugin.json")) && + fs.existsSync(path.join(cursor, "package.json")) + ) { + return cursor; + } + const parent = path.dirname(cursor); + if (parent === cursor) { + break; + } + cursor = parent; + } + return path.resolve(path.dirname(fileURLToPath(moduleUrl)), ".."); +} + +export const ACPX_PLUGIN_ROOT = resolveAcpxPluginRoot(); export const ACPX_BUNDLED_BIN = path.join(ACPX_PLUGIN_ROOT, "node_modules", ".bin", ACPX_BIN_NAME); export function buildAcpxLocalInstallCommand(version: string = ACPX_PINNED_VERSION): string { return `npm install --omit=dev --no-save acpx@${version}`; diff --git a/scripts/copy-bundled-plugin-metadata.d.mts b/scripts/copy-bundled-plugin-metadata.d.mts new file mode 100644 index 00000000000..1b2d0e4836d --- /dev/null +++ b/scripts/copy-bundled-plugin-metadata.d.mts @@ -0,0 +1,3 @@ +export function rewritePackageExtensions(entries: unknown): string[] | undefined; + +export function copyBundledPluginMetadata(params?: { repoRoot?: string }): void; diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs index a137872d421..af8612a3465 100644 --- a/scripts/copy-bundled-plugin-metadata.mjs +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -3,7 +3,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { removeFileIfExists, writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs"; -function rewritePackageExtensions(entries) { +export function rewritePackageExtensions(entries) { if (!Array.isArray(entries)) { return undefined; } @@ -17,8 +17,50 @@ function rewritePackageExtensions(entries) { }); } +function ensurePathInsideRoot(rootDir, rawPath) { + const resolved = path.resolve(rootDir, rawPath); + const relative = path.relative(rootDir, resolved); + if ( + relative === "" || + relative === "." || + (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)) + ) { + return resolved; + } + throw new Error(`path escapes plugin root: ${rawPath}`); +} + +function copyDeclaredPluginSkillPaths(params) { + const skills = Array.isArray(params.manifest.skills) ? params.manifest.skills : []; + const copiedSkills = []; + for (const raw of skills) { + if (typeof raw !== "string" || raw.trim().length === 0) { + continue; + } + const normalized = raw.replace(/^\.\//u, ""); + const sourcePath = ensurePathInsideRoot(params.pluginDir, raw); + if (!fs.existsSync(sourcePath)) { + // Some Docker/lightweight builds intentionally omit optional plugin-local + // dependencies. Only advertise skill paths that were actually bundled. + console.warn( + `[bundled-plugin-metadata] skipping missing skill path ${sourcePath} (plugin ${params.manifest.id ?? path.basename(params.pluginDir)})`, + ); + continue; + } + const targetPath = ensurePathInsideRoot(params.distPluginDir, normalized); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.cpSync(sourcePath, targetPath, { + dereference: true, + force: true, + recursive: true, + }); + copiedSkills.push(raw); + } + return copiedSkills; +} + export function copyBundledPluginMetadata(params = {}) { - const repoRoot = params.cwd ?? process.cwd(); + const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); const extensionsRoot = path.join(repoRoot, "extensions"); const distExtensionsRoot = path.join(repoRoot, "dist", "extensions"); if (!fs.existsSync(extensionsRoot)) { @@ -44,7 +86,12 @@ export function copyBundledPluginMetadata(params = {}) { continue; } - writeTextFileIfChanged(distManifestPath, fs.readFileSync(manifestPath, "utf8")); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const copiedSkills = copyDeclaredPluginSkillPaths({ manifest, pluginDir, distPluginDir }); + const bundledManifest = Array.isArray(manifest.skills) + ? { ...manifest, skills: copiedSkills } + : manifest; + writeTextFileIfChanged(distManifestPath, `${JSON.stringify(bundledManifest, null, 2)}\n`); const packageJsonPath = path.join(pluginDir, "package.json"); if (!fs.existsSync(packageJsonPath)) { diff --git a/src/plugins/copy-bundled-plugin-metadata.test.ts b/src/plugins/copy-bundled-plugin-metadata.test.ts new file mode 100644 index 00000000000..46036dc45d9 --- /dev/null +++ b/src/plugins/copy-bundled-plugin-metadata.test.ts @@ -0,0 +1,144 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + copyBundledPluginMetadata, + rewritePackageExtensions, +} from "../../scripts/copy-bundled-plugin-metadata.mjs"; + +const tempDirs: string[] = []; + +function makeRepoRoot(prefix: string): string { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(repoRoot); + return repoRoot; +} + +function writeJson(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("rewritePackageExtensions", () => { + it("rewrites TypeScript extension entries to built JS paths", () => { + expect(rewritePackageExtensions(["./index.ts", "./nested/entry.mts"])).toEqual([ + "./index.js", + "./nested/entry.js", + ]); + }); +}); + +describe("copyBundledPluginMetadata", () => { + it("copies plugin manifests, package metadata, and local skill directories", () => { + const repoRoot = makeRepoRoot("openclaw-bundled-plugin-meta-"); + const pluginDir = path.join(repoRoot, "extensions", "acpx"); + fs.mkdirSync(path.join(pluginDir, "skills", "acp-router"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "skills", "acp-router", "SKILL.md"), + "# ACP Router\n", + "utf8", + ); + writeJson(path.join(pluginDir, "openclaw.plugin.json"), { + id: "acpx", + configSchema: { type: "object" }, + skills: ["./skills"], + }); + writeJson(path.join(pluginDir, "package.json"), { + name: "@openclaw/acpx", + openclaw: { extensions: ["./index.ts"] }, + }); + + copyBundledPluginMetadata({ repoRoot }); + + expect( + fs.existsSync(path.join(repoRoot, "dist", "extensions", "acpx", "openclaw.plugin.json")), + ).toBe(true); + expect( + fs.readFileSync( + path.join(repoRoot, "dist", "extensions", "acpx", "skills", "acp-router", "SKILL.md"), + "utf8", + ), + ).toContain("ACP Router"); + const packageJson = JSON.parse( + fs.readFileSync(path.join(repoRoot, "dist", "extensions", "acpx", "package.json"), "utf8"), + ) as { openclaw?: { extensions?: string[] } }; + expect(packageJson.openclaw?.extensions).toEqual(["./index.js"]); + }); + + it("dereferences node_modules-backed skill paths into the bundled dist tree", () => { + const repoRoot = makeRepoRoot("openclaw-bundled-plugin-node-modules-"); + const pluginDir = path.join(repoRoot, "extensions", "tlon"); + const storeSkillDir = path.join( + repoRoot, + "node_modules", + ".pnpm", + "@tloncorp+tlon-skill@0.2.2", + "node_modules", + "@tloncorp", + "tlon-skill", + ); + fs.mkdirSync(storeSkillDir, { recursive: true }); + fs.writeFileSync(path.join(storeSkillDir, "SKILL.md"), "# Tlon Skill\n", "utf8"); + fs.mkdirSync(path.join(pluginDir, "node_modules", "@tloncorp"), { recursive: true }); + fs.symlinkSync( + storeSkillDir, + path.join(pluginDir, "node_modules", "@tloncorp", "tlon-skill"), + process.platform === "win32" ? "junction" : "dir", + ); + writeJson(path.join(pluginDir, "openclaw.plugin.json"), { + id: "tlon", + configSchema: { type: "object" }, + skills: ["node_modules/@tloncorp/tlon-skill"], + }); + writeJson(path.join(pluginDir, "package.json"), { + name: "@openclaw/tlon", + openclaw: { extensions: ["./index.ts"] }, + }); + + copyBundledPluginMetadata({ repoRoot }); + + const copiedSkillDir = path.join( + repoRoot, + "dist", + "extensions", + "tlon", + "node_modules", + "@tloncorp", + "tlon-skill", + ); + expect(fs.existsSync(path.join(copiedSkillDir, "SKILL.md"))).toBe(true); + expect(fs.lstatSync(copiedSkillDir).isSymbolicLink()).toBe(false); + }); + + it("omits missing declared skill paths from the bundled manifest", () => { + const repoRoot = makeRepoRoot("openclaw-bundled-plugin-missing-skill-"); + const pluginDir = path.join(repoRoot, "extensions", "tlon"); + fs.mkdirSync(pluginDir, { recursive: true }); + writeJson(path.join(pluginDir, "openclaw.plugin.json"), { + id: "tlon", + configSchema: { type: "object" }, + skills: ["node_modules/@tloncorp/tlon-skill"], + }); + writeJson(path.join(pluginDir, "package.json"), { + name: "@openclaw/tlon", + openclaw: { extensions: ["./index.ts"] }, + }); + + copyBundledPluginMetadata({ repoRoot }); + + const bundledManifest = JSON.parse( + fs.readFileSync( + path.join(repoRoot, "dist", "extensions", "tlon", "openclaw.plugin.json"), + "utf8", + ), + ) as { skills?: string[] }; + expect(bundledManifest.skills).toEqual([]); + }); +}); From 373515676607b285818a4b7e29eb4176680c0afc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 14:14:30 -0700 Subject: [PATCH 023/943] fix(ci): restore config baseline release-check output (#47629) * Docs: regenerate config baseline * Chore: ignore generated config baseline * Update .prettierignore Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .prettierignore | 1 + docs/.generated/config-baseline.json | 8086 ++++++++++++++++++++------ 2 files changed, 6413 insertions(+), 1674 deletions(-) create mode 100644 .prettierignore diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000000..8af8b9e55d1 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +docs/.generated/ diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index 4974f3a410a..f6f854b2946 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -8,7 +8,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP", "help": "ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.", "hasChildren": true @@ -20,7 +22,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "ACP Allowed Agents", "help": "Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.", "hasChildren": true @@ -42,7 +46,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Backend", "help": "Default ACP runtime backend id (for example: acpx). Must match a registered ACP runtime plugin backend.", "hasChildren": false @@ -54,7 +60,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Default Agent", "help": "Fallback ACP target agent id used when ACP spawns do not specify an explicit target.", "hasChildren": false @@ -76,7 +84,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Dispatch Enabled", "help": "Independent dispatch gate for ACP session turns (default: true). Set false to keep ACP commands available while blocking ACP turn execution.", "hasChildren": false @@ -88,7 +98,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Enabled", "help": "Global ACP feature gate. Keep disabled unless ACP runtime + policy are configured.", "hasChildren": false @@ -100,7 +112,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "ACP Max Concurrent Sessions", "help": "Maximum concurrently active ACP sessions across this gateway process.", "hasChildren": false @@ -122,7 +137,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Runtime Install Command", "help": "Optional operator install/setup command shown by `/acp install` and `/acp doctor` when ACP backend wiring is missing.", "hasChildren": false @@ -134,7 +151,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Runtime TTL (minutes)", "help": "Idle runtime TTL in minutes for ACP session workers before eligible cleanup.", "hasChildren": false @@ -146,7 +165,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Stream", "help": "ACP streaming projection controls for chunk sizing, metadata visibility, and deduped delivery behavior.", "hasChildren": true @@ -158,7 +179,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Stream Coalesce Idle (ms)", "help": "Coalescer idle flush window in milliseconds for ACP streamed text before block replies are emitted.", "hasChildren": false @@ -170,7 +193,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Stream Delivery Mode", "help": "ACP delivery style: live streams projected output incrementally, final_only buffers all projected ACP output until terminal turn events.", "hasChildren": false @@ -182,7 +207,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Stream Hidden Boundary Separator", "help": "Separator inserted before next visible assistant text when hidden ACP tool lifecycle events occurred (none|space|newline|paragraph). Default: paragraph.", "hasChildren": false @@ -194,7 +221,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "ACP Stream Max Chunk Chars", "help": "Maximum chunk size for ACP streamed block projection before splitting into multiple block replies.", "hasChildren": false @@ -206,7 +235,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "ACP Stream Max Output Chars", "help": "Maximum assistant output characters projected per ACP turn before truncation notice is emitted.", "hasChildren": false @@ -218,7 +249,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "ACP Stream Max Session Update Chars", "help": "Maximum characters for projected ACP session/update lines (tool/status updates).", "hasChildren": false @@ -230,7 +264,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Stream Repeat Suppression", "help": "When true (default), suppress repeated ACP status/tool projection lines in a turn while keeping raw ACP events unchanged.", "hasChildren": false @@ -242,7 +278,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Stream Tag Visibility", "help": "Per-sessionUpdate visibility overrides for ACP projection (for example usage_update, available_commands_update).", "hasChildren": true @@ -264,7 +302,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agents", "help": "Agent runtime configuration root covering defaults and explicit agent entries used for routing and execution context. Keep this section explicit so model/tool behavior stays predictable across multi-agent workflows.", "hasChildren": true @@ -276,7 +316,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent Defaults", "help": "Shared default settings inherited by agents unless overridden per entry in agents.list. Use defaults to enforce consistent baseline behavior and reduce duplicated per-agent configuration.", "hasChildren": true @@ -388,7 +430,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Bootstrap Max Chars", "help": "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", "hasChildren": false @@ -400,7 +444,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Bootstrap Prompt Truncation Warning", "help": "Inject agent-visible warning text when bootstrap files are truncated: \"off\", \"once\" (default), or \"always\".", "hasChildren": false @@ -412,7 +458,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Bootstrap Total Max Chars", "help": "Max total characters across all injected workspace bootstrap files (default: 150000).", "hasChildren": false @@ -424,7 +472,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "CLI Backends", "help": "Optional CLI backends for text-only fallback (claude-cli, etc.).", "hasChildren": true @@ -846,7 +896,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction", "help": "Compaction tuning for when context nears token limits, including history share, reserve headroom, and pre-compaction memory flush behavior. Use this when long-running sessions need stable continuity under tight context windows.", "hasChildren": true @@ -868,7 +920,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Identifier Instructions", "help": "Custom identifier-preservation instruction text used when identifierPolicy=\"custom\". Keep this explicit and safety-focused so compaction summaries do not rewrite opaque IDs, URLs, hosts, or ports.", "hasChildren": false @@ -880,7 +934,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Compaction Identifier Policy", "help": "Identifier-preservation policy for compaction summaries: \"strict\" prepends built-in opaque-identifier retention guidance (default), \"off\" disables this prefix, and \"custom\" uses identifierInstructions. Keep \"strict\" unless you have a specific compatibility need.", "hasChildren": false @@ -892,7 +948,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "label": "Compaction Keep Recent Tokens", "help": "Minimum token budget preserved from the most recent conversation window during compaction. Use higher values to protect immediate context continuity and lower values to keep more long-tail history.", "hasChildren": false @@ -904,7 +963,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Compaction Max History Share", "help": "Maximum fraction of total context budget allowed for retained history after compaction (range 0.1-0.9). Use lower shares for more generation headroom or higher shares for deeper historical continuity.", "hasChildren": false @@ -916,7 +977,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Memory Flush", "help": "Pre-compaction memory flush settings that run an agentic memory write before heavy compaction. Keep enabled for long sessions so salient context is persisted before aggressive trimming.", "hasChildren": true @@ -928,7 +991,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Memory Flush Enabled", "help": "Enables pre-compaction memory flush before the runtime performs stronger history reduction near token limits. Keep enabled unless you intentionally disable memory side effects in constrained environments.", "hasChildren": false @@ -936,11 +1001,16 @@ { "path": "agents.defaults.compaction.memoryFlush.forceFlushTranscriptBytes", "kind": "core", - "type": ["integer", "string"], + "type": [ + "integer", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Memory Flush Transcript Size Threshold", "help": "Forces pre-compaction memory flush when transcript file size reaches this threshold (bytes or strings like \"2mb\"). Use this to prevent long-session hangs even when token counters are stale; set to 0 to disable.", "hasChildren": false @@ -952,7 +1022,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Memory Flush Prompt", "help": "User-prompt template used for the pre-compaction memory flush turn when generating memory candidates. Use this only when you need custom extraction instructions beyond the default memory flush behavior.", "hasChildren": false @@ -964,7 +1036,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "label": "Compaction Memory Flush Soft Threshold", "help": "Threshold distance to compaction (in tokens) that triggers pre-compaction memory flush execution. Use earlier thresholds for safer persistence, or tighter thresholds for lower flush frequency.", "hasChildren": false @@ -976,7 +1051,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Memory Flush System Prompt", "help": "System-prompt override for the pre-compaction memory flush turn to control extraction style and safety constraints. Use carefully so custom instructions do not reduce memory quality or leak sensitive context.", "hasChildren": false @@ -988,7 +1065,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Mode", "help": "Compaction strategy mode: \"default\" uses baseline behavior, while \"safeguard\" applies stricter guardrails to preserve recent context. Keep \"default\" unless you observe aggressive history loss near limit boundaries.", "hasChildren": false @@ -1000,7 +1079,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Compaction Model Override", "help": "Optional provider/model override used only for compaction summarization. Set this when you want compaction to run on a different model than the session default, and leave it unset to keep using the primary agent model.", "hasChildren": false @@ -1012,7 +1093,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Post-Compaction Context Sections", "help": "AGENTS.md H2/H3 section names re-injected after compaction so the agent reruns critical startup guidance. Leave unset to use \"Session Startup\"/\"Red Lines\" with legacy fallback to \"Every Session\"/\"Safety\"; set to [] to disable reinjection entirely.", "hasChildren": true @@ -1032,10 +1115,16 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["off", "async", "await"], + "enumValues": [ + "off", + "async", + "await" + ], "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Post-Index Sync", "help": "Controls post-compaction session memory reindex mode: \"off\", \"async\", or \"await\" (default: \"async\"). Use \"await\" for strongest freshness, \"async\" for lower compaction latency, and \"off\" only when session-memory sync is handled elsewhere.", "hasChildren": false @@ -1047,7 +1136,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Quality Guard", "help": "Optional quality-audit retry settings for safeguard compaction summaries. Leave this disabled unless you explicitly want summary audits and one-shot regeneration on failed checks.", "hasChildren": true @@ -1059,7 +1150,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Quality Guard Enabled", "help": "Enables summary quality audits and regeneration retries for safeguard compaction. Default: false, so safeguard mode alone does not turn on retry behavior.", "hasChildren": false @@ -1071,7 +1164,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Compaction Quality Guard Max Retries", "help": "Maximum number of regeneration retries after a failed safeguard summary quality audit. Use small values to bound extra latency and token cost.", "hasChildren": false @@ -1083,7 +1178,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Compaction Preserve Recent Turns", "help": "Number of most recent user/assistant turns kept verbatim outside safeguard summarization (default: 3). Raise this to preserve exact recent dialogue context, or lower it to maximize compaction savings.", "hasChildren": false @@ -1095,7 +1192,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "label": "Compaction Reserve Tokens", "help": "Token headroom reserved for reply generation and tool output after compaction runs. Use higher reserves for verbose/tool-heavy sessions, and lower reserves when maximizing retained history matters more.", "hasChildren": false @@ -1107,7 +1207,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "label": "Compaction Reserve Token Floor", "help": "Minimum floor enforced for reserveTokens in Pi compaction paths (0 disables the floor guard). Use a non-zero floor to avoid over-aggressive compression under fluctuating token estimates.", "hasChildren": false @@ -1119,7 +1222,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Compaction Timeout (Seconds)", "help": "Maximum time in seconds allowed for a single compaction operation before it is aborted (default: 900). Increase this for very large sessions that need more time to summarize, or decrease it to fail faster on unresponsive models.", "hasChildren": false @@ -1341,7 +1446,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Embedded Pi", "help": "Embedded Pi runner hardening controls for how workspace-local Pi settings are trusted and applied in OpenClaw sessions.", "hasChildren": true @@ -1353,7 +1460,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Embedded Pi Project Settings Policy", "help": "How embedded Pi handles workspace-local `.pi/config/settings.json`: \"sanitize\" (default) strips shellPath/shellCommandPrefix, \"ignore\" disables project settings entirely, and \"trusted\" applies project settings as-is.", "hasChildren": false @@ -1365,7 +1474,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Envelope Elapsed", "help": "Include elapsed time in message envelopes (\"on\" or \"off\").", "hasChildren": false @@ -1377,7 +1488,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Envelope Timestamp", "help": "Include absolute timestamps in message envelopes (\"on\" or \"off\").", "hasChildren": false @@ -1389,7 +1502,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Envelope Timezone", "help": "Timezone for message envelopes (\"utc\", \"local\", \"user\", or an IANA timezone string).", "hasChildren": false @@ -1471,7 +1586,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "automation", "storage"], + "tags": [ + "access", + "automation", + "storage" + ], "label": "Heartbeat Direct Policy", "help": "Controls whether heartbeat delivery may target direct/DM chats: \"allow\" (default) permits DM delivery and \"block\" suppresses direct-target sends.", "hasChildren": false @@ -1553,7 +1672,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], + "tags": [ + "automation" + ], "label": "Heartbeat Suppress Tool Error Warnings", "help": "Suppress tool error warning payloads during heartbeat runs.", "hasChildren": false @@ -1565,7 +1686,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], + "tags": [ + "automation" + ], "help": "Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.", "hasChildren": false }, @@ -1596,7 +1719,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Human Delay Max (ms)", "help": "Maximum delay in ms for custom humanDelay (default: 2500).", "hasChildren": false @@ -1608,7 +1733,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Human Delay Min (ms)", "help": "Minimum delay in ms for custom humanDelay (default: 800).", "hasChildren": false @@ -1620,7 +1747,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Human Delay Mode", "help": "Delay style for block replies (\"off\", \"natural\", \"custom\").", "hasChildren": false @@ -1632,7 +1761,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance"], + "tags": [ + "media", + "performance" + ], "label": "Image Max Dimension (px)", "help": "Max image side length in pixels when sanitizing transcript/tool-result image payloads (default: 1200).", "hasChildren": false @@ -1640,7 +1772,10 @@ { "path": "agents.defaults.imageModel", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -1654,7 +1789,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "models", "reliability"], + "tags": [ + "media", + "models", + "reliability" + ], "label": "Image Model Fallbacks", "help": "Ordered fallback image models (provider/model).", "hasChildren": true @@ -1676,7 +1815,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "models"], + "tags": [ + "media", + "models" + ], "label": "Image Model", "help": "Optional image model (provider/model) used when the primary model lacks image input.", "hasChildren": false @@ -1708,7 +1850,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search", "help": "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", "hasChildren": true @@ -1730,7 +1874,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Memory Search Embedding Cache", "help": "Caches computed chunk embeddings in SQLite so reindexing and incremental updates run faster (default: true). Keep this enabled unless investigating cache correctness or minimizing disk usage.", "hasChildren": false @@ -1742,7 +1888,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "Memory Search Embedding Cache Max Entries", "help": "Sets a best-effort upper bound on cached embeddings kept in SQLite for memory search. Use this when controlling disk growth matters more than peak reindex speed.", "hasChildren": false @@ -1764,7 +1913,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Chunk Overlap Tokens", "help": "Token overlap between adjacent memory chunks to preserve context continuity near split boundaries. Use modest overlap to reduce boundary misses without inflating index size too aggressively.", "hasChildren": false @@ -1776,7 +1927,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "label": "Memory Chunk Tokens", "help": "Chunk size in tokens used when splitting memory sources before embedding/indexing. Increase for broader context per chunk, or lower to improve precision on pinpoint lookups.", "hasChildren": false @@ -1788,7 +1942,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable Memory Search", "help": "Master toggle for memory search indexing and retrieval behavior on this agent profile. Keep enabled for semantic recall, and disable when you want fully stateless responses.", "hasChildren": false @@ -1810,7 +1966,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "security", "storage"], + "tags": [ + "advanced", + "security", + "storage" + ], "label": "Memory Search Session Index (Experimental)", "help": "Indexes session transcripts into memory search so responses can reference prior chat turns. Keep this off unless transcript recall is needed, because indexing cost and storage usage both increase.", "hasChildren": false @@ -1822,7 +1982,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Extra Memory Paths", "help": "Adds extra directories or .md files to the memory index beyond default memory files. Use this when key reference docs live elsewhere in your repo; when multimodal memory is enabled, matching image/audio files under these paths are also eligible for indexing.", "hasChildren": true @@ -1844,7 +2006,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["reliability"], + "tags": [ + "reliability" + ], "label": "Memory Search Fallback", "help": "Backup provider used when primary embeddings fail: \"openai\", \"gemini\", \"voyage\", \"mistral\", \"ollama\", \"local\", or \"none\". Set a real fallback for production reliability; use \"none\" only if you prefer explicit failures.", "hasChildren": false @@ -1876,7 +2040,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Local Embedding Model Path", "help": "Specifies the local embedding model source for local memory search, such as a GGUF file path or `hf:` URI. Use this only when provider is `local`, and verify model compatibility before large index rebuilds.", "hasChildren": false @@ -1888,7 +2054,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Memory Search Model", "help": "Embedding model override used by the selected memory provider when a non-default model is required. Set this only when you need explicit recall quality/cost tuning beyond provider defaults.", "hasChildren": false @@ -1900,7 +2068,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Multimodal", "help": "Optional multimodal memory settings for indexing image and audio files from configured extra paths. Keep this off unless your embedding model explicitly supports cross-modal embeddings, and set `memorySearch.fallback` to \"none\" while it is enabled. Matching files are uploaded to the configured remote embedding provider during indexing.", "hasChildren": true @@ -1912,7 +2082,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable Memory Search Multimodal", "help": "Enables image/audio memory indexing from extraPaths. This currently requires Gemini embedding-2, keeps the default memory roots Markdown-only, disables memory-search fallback providers, and uploads matching binary content to the configured remote embedding provider.", "hasChildren": false @@ -1924,7 +2096,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "Memory Search Multimodal Max File Bytes", "help": "Sets the maximum bytes allowed per multimodal file before it is skipped during memory indexing. Use this to cap upload cost and indexing latency, or raise it for short high-quality audio clips.", "hasChildren": false @@ -1936,7 +2111,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Multimodal Modalities", "help": "Selects which multimodal file types are indexed from extraPaths: \"image\", \"audio\", or \"all\". Keep this narrow to avoid indexing large binary corpora unintentionally.", "hasChildren": true @@ -1958,7 +2135,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Output Dimensionality", "help": "Gemini embedding-2 only: chooses the output vector size for memory embeddings. Use 768, 1536, or 3072 (default), and expect a full reindex when you change it because stored vector dimensions must stay consistent.", "hasChildren": false @@ -1970,7 +2149,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Provider", "help": "Selects the embedding backend used to build/query memory vectors: \"openai\", \"gemini\", \"voyage\", \"mistral\", \"ollama\", or \"local\". Keep your most reliable provider here and configure fallback for resilience.", "hasChildren": false @@ -2002,7 +2183,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Hybrid Candidate Multiplier", "help": "Expands the candidate pool before reranking (default: 4). Raise this for better recall on noisy corpora, but expect more compute and slightly slower searches.", "hasChildren": false @@ -2014,7 +2197,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Hybrid", "help": "Combines BM25 keyword matching with vector similarity for better recall on mixed exact + semantic queries. Keep enabled unless you are isolating ranking behavior for troubleshooting.", "hasChildren": false @@ -2036,7 +2221,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search MMR Re-ranking", "help": "Adds MMR reranking to diversify results and reduce near-duplicate snippets in a single answer window. Enable when recall looks repetitive; keep off for strict score ordering.", "hasChildren": false @@ -2048,7 +2235,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search MMR Lambda", "help": "Sets MMR relevance-vs-diversity balance (0 = most diverse, 1 = most relevant, default: 0.7). Lower values reduce repetition; higher values keep tightly relevant but may duplicate.", "hasChildren": false @@ -2070,7 +2259,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Temporal Decay", "help": "Applies recency decay so newer memory can outrank older memory when scores are close. Enable when timeliness matters; keep off for timeless reference knowledge.", "hasChildren": false @@ -2082,7 +2273,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Temporal Decay Half-life (Days)", "help": "Controls how fast older memory loses rank when temporal decay is enabled (half-life in days, default: 30). Lower values prioritize recent context more aggressively.", "hasChildren": false @@ -2094,7 +2287,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Text Weight", "help": "Controls how strongly BM25 keyword relevance influences hybrid ranking (0-1). Increase for exact-term matching; decrease when semantic matches should rank higher.", "hasChildren": false @@ -2106,7 +2301,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Vector Weight", "help": "Controls how strongly semantic similarity influences hybrid ranking (0-1). Increase when paraphrase matching matters more than exact terms; decrease for stricter keyword emphasis.", "hasChildren": false @@ -2118,7 +2315,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Memory Search Max Results", "help": "Maximum number of memory hits returned from search before downstream reranking and prompt injection. Raise for broader recall, or lower for tighter prompts and faster responses.", "hasChildren": false @@ -2130,7 +2329,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Min Score", "help": "Minimum relevance score threshold for including memory results in final recall output. Increase to reduce weak/noisy matches, or lower when you need more permissive retrieval.", "hasChildren": false @@ -2148,11 +2349,17 @@ { "path": "agents.defaults.memorySearch.remote.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "label": "Remote Embedding API Key", "help": "Supplies a dedicated API key for remote embedding calls used by memory indexing and query-time embeddings. Use this when memory embeddings should use different credentials than global defaults or environment variables.", "hasChildren": true @@ -2194,7 +2401,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Remote Embedding Base URL", "help": "Overrides the embedding API endpoint, such as an OpenAI-compatible proxy or custom Gemini base URL. Use this only when routing through your own gateway or vendor endpoint; keep provider defaults otherwise.", "hasChildren": false @@ -2216,7 +2425,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Remote Batch Concurrency", "help": "Limits how many embedding batch jobs run at the same time during indexing (default: 2). Increase carefully for faster bulk indexing, but watch provider rate limits and queue errors.", "hasChildren": false @@ -2228,7 +2439,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Remote Batch Embedding Enabled", "help": "Enables provider batch APIs for embedding jobs when supported (OpenAI/Gemini), improving throughput on larger index runs. Keep this enabled unless debugging provider batch failures or running very small workloads.", "hasChildren": false @@ -2240,7 +2453,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Remote Batch Poll Interval (ms)", "help": "Controls how often the system polls provider APIs for batch job status in milliseconds (default: 2000). Use longer intervals to reduce API chatter, or shorter intervals for faster completion detection.", "hasChildren": false @@ -2252,7 +2467,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Remote Batch Timeout (min)", "help": "Sets the maximum wait time for a full embedding batch operation in minutes (default: 60). Increase for very large corpora or slower providers, and lower it to fail fast in automation-heavy flows.", "hasChildren": false @@ -2264,7 +2481,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Remote Batch Wait for Completion", "help": "Waits for batch embedding jobs to fully finish before the indexing operation completes. Keep this enabled for deterministic indexing state; disable only if you accept delayed consistency.", "hasChildren": false @@ -2276,7 +2495,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Remote Embedding Headers", "help": "Adds custom HTTP headers to remote embedding requests, merged with provider defaults. Use this for proxy auth and tenant routing headers, and keep values minimal to avoid leaking sensitive metadata.", "hasChildren": true @@ -2298,7 +2519,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Search Sources", "help": "Chooses which sources are indexed: \"memory\" reads MEMORY.md + memory files, and \"sessions\" includes transcript history. Keep [\"memory\"] unless you need recall from prior chat transcripts.", "hasChildren": true @@ -2340,7 +2563,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Memory Search Index Path", "help": "Sets where the SQLite memory index is stored on disk for each agent. Keep the default `~/.openclaw/memory/{agentId}.sqlite` unless you need custom storage placement or backup policy alignment.", "hasChildren": false @@ -2362,7 +2587,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Memory Search Vector Index", "help": "Enables the sqlite-vec extension used for vector similarity queries in memory search (default: true). Keep this enabled for normal semantic recall; disable only for debugging or fallback-only operation.", "hasChildren": false @@ -2374,7 +2601,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Memory Search Vector Extension Path", "help": "Overrides the auto-discovered sqlite-vec extension library path (`.dylib`, `.so`, or `.dll`). Use this when your runtime cannot find sqlite-vec automatically or you pin a known-good build.", "hasChildren": false @@ -2406,7 +2635,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Index on Search (Lazy)", "help": "Uses lazy sync by scheduling reindex on search after content changes are detected. Keep enabled for lower idle overhead, or disable if you require pre-synced indexes before any query.", "hasChildren": false @@ -2418,7 +2649,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "storage"], + "tags": [ + "automation", + "storage" + ], "label": "Index on Session Start", "help": "Triggers a memory index sync when a session starts so early turns see fresh memory content. Keep enabled when startup freshness matters more than initial turn latency.", "hasChildren": false @@ -2440,7 +2674,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Delta Bytes", "help": "Requires at least this many newly appended bytes before session transcript changes trigger reindex (default: 100000). Increase to reduce frequent small reindexes, or lower for faster transcript freshness.", "hasChildren": false @@ -2452,7 +2688,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Delta Messages", "help": "Requires at least this many appended transcript messages before reindex is triggered (default: 50). Lower this for near-real-time transcript recall, or raise it to reduce indexing churn.", "hasChildren": false @@ -2464,7 +2702,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Force Reindex After Compaction", "help": "Forces a session memory-search reindex after compaction-triggered transcript updates (default: true). Keep enabled when compacted summaries must be immediately searchable, or disable to reduce write-time indexing pressure.", "hasChildren": false @@ -2476,7 +2716,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Watch Memory Files", "help": "Watches memory files and schedules index updates from file-change events (chokidar). Enable for near-real-time freshness; disable on very large workspaces if watch churn is too noisy.", "hasChildren": false @@ -2488,7 +2730,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "performance"], + "tags": [ + "automation", + "performance" + ], "label": "Memory Watch Debounce (ms)", "help": "Debounce window in milliseconds for coalescing rapid file-watch events before reindex runs. Increase to reduce churn on frequently-written files, or lower for faster freshness.", "hasChildren": false @@ -2496,7 +2741,10 @@ { "path": "agents.defaults.model", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -2510,7 +2758,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models", "reliability"], + "tags": [ + "models", + "reliability" + ], "label": "Model Fallbacks", "help": "Ordered fallback models (provider/model). Used when the primary model fails.", "hasChildren": true @@ -2532,7 +2783,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Primary Model", "help": "Primary model (provider/model).", "hasChildren": false @@ -2544,7 +2797,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Models", "help": "Configured model catalog (keys are full provider/model IDs).", "hasChildren": true @@ -2605,7 +2860,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "PDF Max Size (MB)", "help": "Maximum PDF file size in megabytes for the PDF tool (default: 10).", "hasChildren": false @@ -2617,7 +2874,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "PDF Max Pages", "help": "Maximum number of PDF pages to process for the PDF tool (default: 20).", "hasChildren": false @@ -2625,7 +2884,10 @@ { "path": "agents.defaults.pdfModel", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -2639,7 +2901,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["reliability"], + "tags": [ + "reliability" + ], "label": "PDF Model Fallbacks", "help": "Ordered fallback PDF models (provider/model).", "hasChildren": true @@ -2661,7 +2925,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "PDF Model", "help": "Optional PDF model (provider/model) for the PDF analysis tool. Defaults to imageModel, then session model.", "hasChildren": false @@ -2673,7 +2939,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Repo Root", "help": "Optional repository root shown in the system prompt runtime line (overrides auto-detect).", "hasChildren": false @@ -2765,7 +3033,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Sandbox Browser CDP Source Port Range", "help": "Optional CIDR allowlist for container-edge CDP ingress (for example 172.21.0.1/32).", "hasChildren": false @@ -2827,7 +3097,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Sandbox Browser Network", "help": "Docker network for sandbox browser containers (default: openclaw-sandbox-browser). Avoid bridge if you need stricter isolation.", "hasChildren": false @@ -2939,7 +3211,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "advanced", "security", "storage"], + "tags": [ + "access", + "advanced", + "security", + "storage" + ], "label": "Sandbox Docker Allow Container Namespace Join", "help": "DANGEROUS break-glass override that allows sandbox Docker network mode container:. This joins another container namespace and weakens sandbox isolation.", "hasChildren": false @@ -3037,7 +3314,10 @@ { "path": "agents.defaults.sandbox.docker.memory", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -3047,7 +3327,10 @@ { "path": "agents.defaults.sandbox.docker.memorySwap", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -3136,7 +3419,11 @@ { "path": "agents.defaults.sandbox.docker.ulimits.*", "kind": "core", - "type": ["number", "object", "string"], + "type": [ + "number", + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -3346,7 +3633,10 @@ { "path": "agents.defaults.subagents.model", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -3480,7 +3770,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Workspace", "help": "Default workspace path exposed to agent runtime tools for filesystem context and repo-aware behavior. Set this explicitly when running from wrappers so path resolution stays deterministic.", "hasChildren": false @@ -3492,7 +3784,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent List", "help": "Explicit list of configured agents with IDs and optional overrides for model, tools, identity, and workspace. Keep IDs stable over time so bindings, approvals, and session routing remain deterministic.", "hasChildren": true @@ -3644,7 +3938,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "automation", "storage"], + "tags": [ + "access", + "automation", + "storage" + ], "label": "Heartbeat Direct Policy", "help": "Per-agent override for heartbeat direct/DM delivery policy; use \"block\" for agents that should only send heartbeat alerts to non-DM destinations.", "hasChildren": false @@ -3726,7 +4024,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], + "tags": [ + "automation" + ], "label": "Agent Heartbeat Suppress Tool Error Warnings", "help": "Suppress tool error warning payloads during heartbeat runs.", "hasChildren": false @@ -3738,7 +4038,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], + "tags": [ + "automation" + ], "help": "Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.", "hasChildren": false }, @@ -3819,7 +4121,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Identity Avatar", "help": "Agent avatar (workspace-relative path, http(s) URL, or data URI).", "hasChildren": false @@ -4247,11 +4551,17 @@ { "path": "agents.list.*.memorySearch.remote.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "hasChildren": true }, { @@ -4557,7 +4867,10 @@ { "path": "agents.list.*.model", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -4630,7 +4943,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent Runtime", "help": "Optional runtime descriptor for this agent. Use embedded for default OpenClaw execution or acp for external ACP harness defaults.", "hasChildren": true @@ -4642,7 +4957,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent ACP Runtime", "help": "ACP runtime defaults for this agent when runtime.type=acp. Binding-level ACP overrides still take precedence per conversation.", "hasChildren": true @@ -4654,7 +4971,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent ACP Harness Agent", "help": "Optional ACP harness agent id to use for this OpenClaw agent (for example codex, claude).", "hasChildren": false @@ -4666,7 +4985,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent ACP Backend", "help": "Optional ACP backend override for this agent's ACP sessions (falls back to global acp.backend).", "hasChildren": false @@ -4678,7 +4999,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent ACP Working Directory", "help": "Optional default working directory for this agent's ACP sessions.", "hasChildren": false @@ -4688,10 +5011,15 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["persistent", "oneshot"], + "enumValues": [ + "persistent", + "oneshot" + ], "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent ACP Mode", "help": "Optional ACP session mode default for this agent (persistent or oneshot).", "hasChildren": false @@ -4703,7 +5031,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent Runtime Type", "help": "Runtime type for this agent: \"embedded\" (default OpenClaw runtime) or \"acp\" (ACP harness defaults).", "hasChildren": false @@ -4795,7 +5125,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Agent Sandbox Browser CDP Source Port Range", "help": "Per-agent override for CDP source CIDR allowlist.", "hasChildren": false @@ -4857,7 +5189,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Agent Sandbox Browser Network", "help": "Per-agent override for sandbox browser Docker network.", "hasChildren": false @@ -4969,7 +5303,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "advanced", "security", "storage"], + "tags": [ + "access", + "advanced", + "security", + "storage" + ], "label": "Agent Sandbox Docker Allow Container Namespace Join", "help": "Per-agent DANGEROUS override for container namespace joins in sandbox Docker network mode.", "hasChildren": false @@ -5067,7 +5406,10 @@ { "path": "agents.list.*.sandbox.docker.memory", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -5077,7 +5419,10 @@ { "path": "agents.list.*.sandbox.docker.memorySwap", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -5166,7 +5511,11 @@ { "path": "agents.list.*.sandbox.docker.ulimits.*", "kind": "core", - "type": ["number", "object", "string"], + "type": [ + "number", + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -5310,7 +5659,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent Skill Filter", "help": "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", "hasChildren": true @@ -5358,7 +5709,10 @@ { "path": "agents.list.*.subagents.model", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -5442,7 +5796,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Agent Tool Allowlist Additions", "help": "Per-agent additive allowlist for tools on top of global and profile policy. Keep narrow to avoid accidental privilege expansion on specialized agents.", "hasChildren": true @@ -5464,7 +5820,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Agent Tool Policy by Provider", "help": "Per-agent provider-specific tool policy overrides for channel-scoped capability control. Use this when a single agent needs tighter restrictions on one provider than others.", "hasChildren": true @@ -5602,7 +5960,10 @@ { "path": "agents.list.*.tools.elevated.allowFrom.*.*", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -5694,7 +6055,11 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["off", "on-miss", "always"], + "enumValues": [ + "off", + "on-miss", + "always" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -5725,7 +6090,11 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["sandbox", "gateway", "node"], + "enumValues": [ + "sandbox", + "gateway", + "node" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -5906,7 +6275,11 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["deny", "allowlist", "full"], + "enumValues": [ + "deny", + "allowlist", + "full" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -6049,7 +6422,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Agent Tool Profile", "help": "Per-agent override for tool profile selection when one agent needs a different capability baseline. Use this sparingly so policy differences across agents stay intentional and reviewable.", "hasChildren": false @@ -6151,7 +6526,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Approvals", "help": "Approval routing controls for forwarding exec approval requests to chat destinations outside the originating session. Keep this disabled unless operators need explicit out-of-band approval visibility.", "hasChildren": true @@ -6163,7 +6540,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Exec Approval Forwarding", "help": "Groups exec-approval forwarding behavior including enablement, routing mode, filters, and explicit targets. Configure here when approval prompts must reach operational channels instead of only the origin thread.", "hasChildren": true @@ -6175,7 +6554,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Approval Agent Filter", "help": "Optional allowlist of agent IDs eligible for forwarded approvals, for example `[\"primary\", \"ops-agent\"]`. Use this to limit forwarding blast radius and avoid notifying channels for unrelated agents.", "hasChildren": true @@ -6197,7 +6578,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Forward Exec Approvals", "help": "Enables forwarding of exec approval requests to configured delivery destinations (default: false). Keep disabled in low-risk setups and enable only when human approval responders need channel-visible prompts.", "hasChildren": false @@ -6209,7 +6592,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Approval Forwarding Mode", "help": "Controls where approval prompts are sent: \"session\" uses origin chat, \"targets\" uses configured targets, and \"both\" sends to both paths. Use \"session\" as baseline and expand only when operational workflow requires redundancy.", "hasChildren": false @@ -6221,7 +6606,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Approval Session Filter", "help": "Optional session-key filters matched as substring or regex-style patterns, for example `[\"discord:\", \"^agent:ops:\"]`. Use narrow patterns so only intended approval contexts are forwarded to shared destinations.", "hasChildren": true @@ -6243,7 +6630,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Approval Forwarding Targets", "help": "Explicit delivery targets used when forwarding mode includes targets, each with channel and destination details. Keep target lists least-privilege and validate each destination before enabling broad forwarding.", "hasChildren": true @@ -6265,7 +6654,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Approval Target Account ID", "help": "Optional account selector for multi-account channel setups when approvals must route through a specific account context. Use this only when the target channel has multiple configured identities.", "hasChildren": false @@ -6277,7 +6668,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Approval Target Channel", "help": "Channel/provider ID used for forwarded approval delivery, such as discord, slack, or a plugin channel id. Use valid channel IDs only so approvals do not silently fail due to unknown routes.", "hasChildren": false @@ -6285,11 +6678,16 @@ { "path": "approvals.exec.targets.*.threadId", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Approval Target Thread ID", "help": "Optional thread/topic target for channels that support threaded delivery of forwarded approvals. Use this to keep approval traffic contained in operational threads instead of main channels.", "hasChildren": false @@ -6301,7 +6699,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Approval Target Destination", "help": "Destination identifier inside the target channel (channel ID, user ID, or thread root depending on provider). Verify semantics per provider because destination format differs across channel integrations.", "hasChildren": false @@ -6313,7 +6713,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Audio", "help": "Global audio ingestion settings used before higher-level tools process speech or media content. Configure this when you need deterministic transcription behavior for voice notes and clips.", "hasChildren": true @@ -6325,7 +6727,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Audio Transcription", "help": "Command-based transcription settings for converting audio files into text before agent handling. Keep a simple, deterministic command path here so failures are easy to diagnose in logs.", "hasChildren": true @@ -6337,7 +6741,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Audio Transcription Command", "help": "Executable + args used to transcribe audio (first token must be a safe binary/path), for example `[\"whisper-cli\", \"--model\", \"small\", \"{input}\"]`. Prefer a pinned command so runtime environments behave consistently.", "hasChildren": true @@ -6359,7 +6765,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance"], + "tags": [ + "media", + "performance" + ], "label": "Audio Transcription Timeout (sec)", "help": "Maximum time allowed for the transcription command to finish before it is aborted. Increase this for longer recordings, and keep it tight in latency-sensitive deployments.", "hasChildren": false @@ -6371,7 +6780,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Auth", "help": "Authentication profile root used for multi-profile provider credentials and cooldown-based failover ordering. Keep profiles minimal and explicit so automatic failover behavior stays auditable.", "hasChildren": true @@ -6383,7 +6794,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "auth"], + "tags": [ + "access", + "auth" + ], "label": "Auth Cooldowns", "help": "Cooldown/backoff controls for temporary profile suppression after billing-related failures and retry windows. Use these to prevent rapid re-selection of profiles that are still blocked.", "hasChildren": true @@ -6395,7 +6809,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "auth", "reliability"], + "tags": [ + "access", + "auth", + "reliability" + ], "label": "Billing Backoff (hours)", "help": "Base backoff (hours) when a profile fails due to billing/insufficient credits (default: 5).", "hasChildren": false @@ -6407,7 +6825,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "auth", "reliability"], + "tags": [ + "access", + "auth", + "reliability" + ], "label": "Billing Backoff Overrides", "help": "Optional per-provider overrides for billing backoff (hours).", "hasChildren": true @@ -6429,7 +6851,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "auth", "performance"], + "tags": [ + "access", + "auth", + "performance" + ], "label": "Billing Backoff Cap (hours)", "help": "Cap (hours) for billing backoff (default: 24).", "hasChildren": false @@ -6441,7 +6867,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "auth"], + "tags": [ + "access", + "auth" + ], "label": "Failover Window (hours)", "help": "Failure window (hours) for backoff counters (default: 24).", "hasChildren": false @@ -6453,7 +6882,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "auth"], + "tags": [ + "access", + "auth" + ], "label": "Auth Profile Order", "help": "Ordered auth profile IDs per provider (used for automatic failover).", "hasChildren": true @@ -6485,7 +6917,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "auth", "storage"], + "tags": [ + "access", + "auth", + "storage" + ], "label": "Auth Profiles", "help": "Named auth profiles (provider + mode + optional email).", "hasChildren": true @@ -6537,7 +6973,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Bindings", "help": "Top-level binding rules for routing and persistent ACP conversation ownership. Use type=route for normal routing and type=acp for persistent ACP harness bindings.", "hasChildren": true @@ -6559,7 +6997,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Binding Overrides", "help": "Optional per-binding ACP overrides for bindings[].type=acp. This layer overrides agents.list[].runtime.acp defaults for the matched conversation.", "hasChildren": true @@ -6571,7 +7011,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Binding Backend", "help": "ACP backend override for this binding (falls back to agent runtime ACP backend, then global acp.backend).", "hasChildren": false @@ -6583,7 +7025,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Binding Working Directory", "help": "Working directory override for ACP sessions created from this binding.", "hasChildren": false @@ -6595,7 +7039,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Binding Label", "help": "Human-friendly label for ACP status/diagnostics in this bound conversation.", "hasChildren": false @@ -6605,10 +7051,15 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["persistent", "oneshot"], + "enumValues": [ + "persistent", + "oneshot" + ], "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACP Binding Mode", "help": "ACP session mode override for this binding (persistent or oneshot).", "hasChildren": false @@ -6620,7 +7071,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Agent ID", "help": "Target agent ID that receives traffic when the corresponding binding match rule is satisfied. Use valid configured agent IDs only so routing does not fail at runtime.", "hasChildren": false @@ -6642,7 +7095,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Match Rule", "help": "Match rule object for deciding when a binding applies, including channel and optional account/peer constraints. Keep rules narrow to avoid accidental agent takeover across contexts.", "hasChildren": true @@ -6654,7 +7109,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Account ID", "help": "Optional account selector for multi-account channel setups so the binding applies only to one identity. Use this when account scoping is required for the route and leave unset otherwise.", "hasChildren": false @@ -6666,7 +7123,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Channel", "help": "Channel/provider identifier this binding applies to, such as `telegram`, `discord`, or a plugin channel ID. Use the configured channel key exactly so binding evaluation works reliably.", "hasChildren": false @@ -6678,7 +7137,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Guild ID", "help": "Optional Discord-style guild/server ID constraint for binding evaluation in multi-server deployments. Use this when the same peer identifiers can appear across different guilds.", "hasChildren": false @@ -6690,7 +7151,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Peer Match", "help": "Optional peer matcher for specific conversations including peer kind and peer id. Use this when only one direct/group/channel target should be pinned to an agent.", "hasChildren": true @@ -6702,7 +7165,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Peer ID", "help": "Conversation identifier used with peer matching, such as a chat ID, channel ID, or group ID from the provider. Keep this exact to avoid silent non-matches.", "hasChildren": false @@ -6714,7 +7179,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Peer Kind", "help": "Peer conversation type: \"direct\", \"group\", \"channel\", or legacy \"dm\" (deprecated alias for direct). Prefer \"direct\" for new configs and keep kind aligned with channel semantics.", "hasChildren": false @@ -6726,7 +7193,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Roles", "help": "Optional role-based filter list used by providers that attach roles to chat context. Use this to route privileged or operational role traffic to specialized agents.", "hasChildren": true @@ -6748,7 +7217,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Team ID", "help": "Optional team/workspace ID constraint used by providers that scope chats under teams. Add this when you need bindings isolated to one workspace context.", "hasChildren": false @@ -6760,7 +7231,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Binding Type", "help": "Binding kind. Use \"route\" (or omit for legacy route entries) for normal routing, and \"acp\" for persistent ACP conversation bindings.", "hasChildren": false @@ -6772,7 +7245,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Broadcast", "help": "Broadcast routing map for sending the same outbound message to multiple peer IDs per source conversation. Keep this minimal and audited because one source can fan out to many destinations.", "hasChildren": true @@ -6784,7 +7259,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Broadcast Destination List", "help": "Per-source broadcast destination list where each key is a source peer ID and the value is an array of destination peer IDs. Keep lists intentional to avoid accidental message amplification.", "hasChildren": true @@ -6804,10 +7281,15 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["parallel", "sequential"], + "enumValues": [ + "parallel", + "sequential" + ], "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Broadcast Strategy", "help": "Delivery order for broadcast fan-out: \"parallel\" sends to all targets concurrently, while \"sequential\" sends one-by-one. Use \"parallel\" for speed and \"sequential\" for stricter ordering/backpressure control.", "hasChildren": false @@ -6819,7 +7301,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser", "help": "Browser runtime controls for local or remote CDP attachment, profile routing, and screenshot/snapshot behavior. Keep defaults unless your automation workflow requires custom browser transport settings.", "hasChildren": true @@ -6831,7 +7315,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser Attach-only Mode", "help": "Restricts browser mode to attach-only behavior without starting local browser processes. Use this when all browser sessions are externally managed by a remote CDP provider.", "hasChildren": false @@ -6843,7 +7329,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser CDP Port Range Start", "help": "Starting local CDP port used for auto-allocated browser profile ports. Increase this when host-level port defaults conflict with other local services.", "hasChildren": false @@ -6855,7 +7343,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser CDP URL", "help": "Remote CDP websocket URL used to attach to an externally managed browser instance. Use this for centralized browser hosts and keep URL access restricted to trusted network paths.", "hasChildren": false @@ -6867,7 +7357,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser Accent Color", "help": "Default accent color used for browser profile/UI cues where colored identity hints are displayed. Use consistent colors to help operators identify active browser profile context quickly.", "hasChildren": false @@ -6879,7 +7371,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Browser Default Profile", "help": "Default browser profile name selected when callers do not explicitly choose a profile. Use a stable low-privilege profile as the default to reduce accidental cross-context state use.", "hasChildren": false @@ -6891,7 +7385,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser Enabled", "help": "Enables browser capability wiring in the gateway so browser tools and CDP-driven workflows can run. Disable when browser automation is not needed to reduce surface area and startup work.", "hasChildren": false @@ -6903,7 +7399,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser Evaluate Enabled", "help": "Enables browser-side evaluate helpers for runtime script evaluation capabilities where supported. Keep disabled unless your workflows require evaluate semantics beyond snapshots/navigation.", "hasChildren": false @@ -6915,7 +7413,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Browser Executable Path", "help": "Explicit browser executable path when auto-discovery is insufficient for your host environment. Use absolute stable paths so launch behavior stays deterministic across restarts.", "hasChildren": false @@ -6947,7 +7447,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser Headless Mode", "help": "Forces browser launch in headless mode when the local launcher starts browser instances. Keep headless enabled for server environments and disable only when visible UI debugging is required.", "hasChildren": false @@ -6959,7 +7461,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Browser No-Sandbox Mode", "help": "Disables Chromium sandbox isolation flags for environments where sandboxing fails at runtime. Keep this off whenever possible because process isolation protections are reduced.", "hasChildren": false @@ -6971,7 +7475,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Browser Profiles", "help": "Named browser profile connection map used for explicit routing to CDP ports or URLs with optional metadata. Keep profile names consistent and avoid overlapping endpoint definitions.", "hasChildren": true @@ -6993,7 +7499,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Browser Profile Attach-only Mode", "help": "Per-profile attach-only override that skips local browser launch and only attaches to an existing CDP endpoint. Useful when one profile is externally managed but others are locally launched.", "hasChildren": false @@ -7005,7 +7513,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Browser Profile CDP Port", "help": "Per-profile local CDP port used when connecting to browser instances by port instead of URL. Use unique ports per profile to avoid connection collisions.", "hasChildren": false @@ -7017,7 +7527,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Browser Profile CDP URL", "help": "Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.", "hasChildren": false @@ -7029,7 +7541,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Browser Profile Accent Color", "help": "Per-profile accent color for visual differentiation in dashboards and browser-related UI hints. Use distinct colors for high-signal operator recognition of active profiles.", "hasChildren": false @@ -7041,7 +7555,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Browser Profile Driver", "help": "Per-profile browser driver mode: \"openclaw\" (or legacy \"clawd\") or \"extension\" depending on connection/runtime strategy. Use the driver that matches your browser control stack to avoid protocol mismatches.", "hasChildren": false @@ -7053,7 +7569,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser Relay Bind Address", "help": "Bind IP address for the Chrome extension relay listener. Leave unset for loopback-only access, or set an explicit non-loopback IP such as 0.0.0.0 only when the relay must be reachable across network namespaces (for example WSL2) and the surrounding network is already trusted.", "hasChildren": false @@ -7065,7 +7583,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Remote CDP Handshake Timeout (ms)", "help": "Timeout in milliseconds for post-connect CDP handshake readiness checks against remote browser targets. Raise this for slow-start remote browsers and lower to fail fast in automation loops.", "hasChildren": false @@ -7077,7 +7597,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Remote CDP Timeout (ms)", "help": "Timeout in milliseconds for connecting to a remote CDP endpoint before failing the browser attach attempt. Increase for high-latency tunnels, or lower for faster failure detection.", "hasChildren": false @@ -7089,7 +7611,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser Snapshot Defaults", "help": "Default snapshot capture configuration used when callers do not provide explicit snapshot options. Tune this for consistent capture behavior across channels and automation paths.", "hasChildren": true @@ -7101,7 +7625,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Browser Snapshot Mode", "help": "Default snapshot extraction mode controlling how page content is transformed for agent consumption. Choose the mode that balances readability, fidelity, and token footprint for your workflows.", "hasChildren": false @@ -7113,7 +7639,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Browser SSRF Policy", "help": "Server-side request forgery guardrail settings for browser/network fetch paths that could reach internal hosts. Keep restrictive defaults in production and open only explicitly approved targets.", "hasChildren": true @@ -7125,7 +7653,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Browser Allowed Hostnames", "help": "Explicit hostname allowlist exceptions for SSRF policy checks on browser/network requests. Keep this list minimal and review entries regularly to avoid stale broad access.", "hasChildren": true @@ -7147,7 +7677,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Browser Allow Private Network", "help": "Legacy alias for browser.ssrfPolicy.dangerouslyAllowPrivateNetwork. Prefer the dangerously-named key so risk intent is explicit.", "hasChildren": false @@ -7159,7 +7691,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "advanced", "security"], + "tags": [ + "access", + "advanced", + "security" + ], "label": "Browser Dangerously Allow Private Network", "help": "Allows access to private-network address ranges from browser tooling. Default is enabled for trusted-network operator setups; disable to enforce strict public-only resolution checks.", "hasChildren": false @@ -7171,7 +7707,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Browser Hostname Allowlist", "help": "Legacy/alternate hostname allowlist field used by SSRF policy consumers for explicit host exceptions. Use stable exact hostnames and avoid wildcard-like broad patterns.", "hasChildren": true @@ -7193,7 +7731,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Canvas Host", "help": "Canvas host settings for serving canvas assets and local live-reload behavior used by canvas-enabled workflows. Keep disabled unless canvas-hosted assets are actively used.", "hasChildren": true @@ -7205,7 +7745,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Canvas Host Enabled", "help": "Enables the canvas host server process and routes for serving canvas files. Keep disabled when canvas workflows are inactive to reduce exposed local services.", "hasChildren": false @@ -7217,7 +7759,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["reliability"], + "tags": [ + "reliability" + ], "label": "Canvas Host Live Reload", "help": "Enables automatic live-reload behavior for canvas assets during development workflows. Keep disabled in production-like environments where deterministic output is preferred.", "hasChildren": false @@ -7229,7 +7773,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Canvas Host Port", "help": "TCP port used by the canvas host HTTP server when canvas hosting is enabled. Choose a non-conflicting port and align firewall/proxy policy accordingly.", "hasChildren": false @@ -7241,7 +7787,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Canvas Host Root Directory", "help": "Filesystem root directory served by canvas host for canvas content and static assets. Use a dedicated directory and avoid broad repo roots for least-privilege file exposure.", "hasChildren": false @@ -7253,7 +7801,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Channels", "help": "Channel provider configurations plus shared defaults that control access policies, heartbeat visibility, and per-surface behavior. Keep defaults centralized and override per provider only where required.", "hasChildren": true @@ -7265,7 +7815,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "BlueBubbles", "help": "iMessage via the BlueBubbles mac app + REST API.", "hasChildren": true @@ -7303,7 +7856,10 @@ { "path": "channels.bluebubbles.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -7335,7 +7891,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -7356,7 +7915,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -7385,7 +7949,10 @@ { "path": "channels.bluebubbles.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -7397,7 +7964,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -7528,7 +8099,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -7577,11 +8152,19 @@ { "path": "channels.bluebubbles.accounts.*.password", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -7798,7 +8381,10 @@ { "path": "channels.bluebubbles.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -7830,7 +8416,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -7861,10 +8450,19 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "BlueBubbles DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.bluebubbles.allowFrom=[\"*\"].", "hasChildren": false @@ -7892,7 +8490,10 @@ { "path": "channels.bluebubbles.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -7904,7 +8505,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -8035,7 +8640,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -8084,11 +8693,19 @@ { "path": "channels.bluebubbles.password", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -8168,7 +8785,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord", "help": "very well supported right now.", "hasChildren": true @@ -8208,7 +8828,14 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["group-mentions", "group-all", "direct", "all", "off", "none"], + "enumValues": [ + "group-mentions", + "group-all", + "direct", + "all", + "off", + "none" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -8467,7 +9094,10 @@ { "path": "channels.discord.accounts.*.allowBots", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -8487,7 +9117,10 @@ { "path": "channels.discord.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -8639,7 +9272,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -8658,7 +9294,10 @@ { "path": "channels.discord.accounts.*.commands.native", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -8668,7 +9307,10 @@ { "path": "channels.discord.accounts.*.commands.nativeSkills", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -8728,7 +9370,10 @@ { "path": "channels.discord.accounts.*.dm.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -8758,7 +9403,10 @@ { "path": "channels.discord.accounts.*.dm.groupChannels.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -8780,7 +9428,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -8801,7 +9454,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -8970,7 +9628,10 @@ { "path": "channels.discord.accounts.*.execApprovals.approvers.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -9022,7 +9683,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["dm", "channel", "both"], + "enumValues": [ + "dm", + "channel", + "both" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -9033,7 +9698,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -9093,9 +9762,17 @@ { "path": "channels.discord.accounts.*.guilds.*.channels.*.autoArchiveDuration", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, - "enumValues": ["60", "1440", "4320", "10080"], + "enumValues": [ + "60", + "1440", + "4320", + "10080" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -9164,7 +9841,10 @@ { "path": "channels.discord.accounts.*.guilds.*.channels.*.roles.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -9364,7 +10044,10 @@ { "path": "channels.discord.accounts.*.guilds.*.channels.*.users.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -9386,7 +10069,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "own", "all", "allowlist"], + "enumValues": [ + "off", + "own", + "all", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -9415,7 +10103,10 @@ { "path": "channels.discord.accounts.*.guilds.*.roles.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -9595,7 +10286,10 @@ { "path": "channels.discord.accounts.*.guilds.*.users.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -9737,7 +10431,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -9796,11 +10494,19 @@ { "path": "channels.discord.accounts.*.pluralkit.token", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -9938,7 +10644,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["online", "dnd", "idle", "invisible"], + "enumValues": [ + "online", + "dnd", + "idle", + "invisible" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -9947,9 +10658,17 @@ { "path": "channels.discord.accounts.*.streaming", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, - "enumValues": ["off", "partial", "block", "progress"], + "enumValues": [ + "off", + "partial", + "block", + "progress" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -9960,7 +10679,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["partial", "block", "off"], + "enumValues": [ + "partial", + "block", + "off" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -10039,11 +10762,19 @@ { "path": "channels.discord.accounts.*.token", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -10201,7 +10932,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "always", "inbound", "tagged"], + "enumValues": [ + "off", + "always", + "inbound", + "tagged" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -10330,11 +11066,20 @@ { "path": "channels.discord.accounts.*.voice.tts.elevenlabs.apiKey", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "media", "network", "security"], + "tags": [ + "auth", + "channels", + "media", + "network", + "security" + ], "hasChildren": true }, { @@ -10372,7 +11117,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["auto", "on", "off"], + "enumValues": [ + "auto", + "on", + "off" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -10513,7 +11262,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["final", "all"], + "enumValues": [ + "final", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -10622,11 +11374,20 @@ { "path": "channels.discord.accounts.*.voice.tts.openai.apiKey", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "media", "network", "security"], + "tags": [ + "auth", + "channels", + "media", + "network", + "security" + ], "hasChildren": true }, { @@ -10724,7 +11485,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["elevenlabs", "openai", "edge"], + "enumValues": [ + "elevenlabs", + "openai", + "edge" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -10765,7 +11530,14 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["group-mentions", "group-all", "direct", "all", "off", "none"], + "enumValues": [ + "group-mentions", + "group-all", + "direct", + "all", + "off", + "none" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -10978,7 +11750,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Presence Activity", "help": "Discord presence activity text (defaults to custom status).", "hasChildren": false @@ -10990,7 +11765,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Presence Activity Type", "help": "Discord presence activity type (0=Playing,1=Streaming,2=Listening,3=Watching,4=Custom,5=Competing).", "hasChildren": false @@ -11002,7 +11780,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Presence Activity URL", "help": "Discord presence streaming URL (required for activityType=1).", "hasChildren": false @@ -11030,11 +11811,18 @@ { "path": "channels.discord.allowBots", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "Discord Allow Bot Messages", "help": "Allow bot-authored messages to trigger Discord replies (default: false). Set \"mentions\" to only accept bot messages that mention the bot.", "hasChildren": false @@ -11052,7 +11840,10 @@ { "path": "channels.discord.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -11076,7 +11867,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Auto Presence Degraded Text", "help": "Optional custom status text while runtime/model availability is degraded or unknown (idle).", "hasChildren": false @@ -11088,7 +11882,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Auto Presence Enabled", "help": "Enable automatic Discord bot presence updates based on runtime/model availability signals. When enabled: healthy=>online, degraded/unknown=>idle, exhausted/unavailable=>dnd.", "hasChildren": false @@ -11100,7 +11897,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Auto Presence Exhausted Text", "help": "Optional custom status text while runtime detects exhausted/unavailable model quota (dnd). Supports {reason} template placeholder.", "hasChildren": false @@ -11112,7 +11912,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "reliability"], + "tags": [ + "channels", + "network", + "reliability" + ], "label": "Discord Auto Presence Healthy Text", "help": "Optional custom status text while runtime is healthy (online). If omitted, falls back to static channels.discord.activity when set.", "hasChildren": false @@ -11124,7 +11928,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "Discord Auto Presence Check Interval (ms)", "help": "How often to evaluate Discord auto-presence state in milliseconds (default: 30000).", "hasChildren": false @@ -11136,7 +11944,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "Discord Auto Presence Min Update Interval (ms)", "help": "Minimum time between actual Discord presence update calls in milliseconds (default: 15000). Prevents status spam on noisy state changes.", "hasChildren": false @@ -11216,7 +12028,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -11235,11 +12050,17 @@ { "path": "channels.discord.commands.native", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Native Commands", "help": "Override native commands for Discord (bool or \"auto\").", "hasChildren": false @@ -11247,11 +12068,17 @@ { "path": "channels.discord.commands.nativeSkills", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Native Skill Commands", "help": "Override native skill commands for Discord (bool or \"auto\").", "hasChildren": false @@ -11263,7 +12090,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Config Writes", "help": "Allow Discord to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -11321,7 +12151,10 @@ { "path": "channels.discord.dm.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -11351,7 +12184,10 @@ { "path": "channels.discord.dm.groupChannels.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -11373,10 +12209,19 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "Discord DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.discord.allowFrom=[\"*\"] (legacy: channels.discord.dm.allowFrom).", "hasChildren": false @@ -11396,10 +12241,19 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "Discord DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.discord.allowFrom=[\"*\"].", "hasChildren": false @@ -11451,7 +12305,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Draft Chunk Break Preference", "help": "Preferred breakpoints for Discord draft chunks (paragraph | newline | sentence). Default: paragraph.", "hasChildren": false @@ -11463,7 +12320,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "Discord Draft Chunk Max Chars", "help": "Target max size for a Discord stream preview chunk when channels.discord.streaming=\"block\" (default: 800; clamped to channels.discord.textChunkLimit).", "hasChildren": false @@ -11475,7 +12336,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Draft Chunk Min Chars", "help": "Minimum chars before emitting a Discord stream preview update when channels.discord.streaming=\"block\" (default: 200).", "hasChildren": false @@ -11507,7 +12371,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "Discord EventQueue Listener Timeout (ms)", "help": "Canonical Discord listener timeout control in ms for gateway normalization/enqueue handlers. Default is 120000 in OpenClaw; set per account via channels.discord.accounts..eventQueue.listenerTimeout.", "hasChildren": false @@ -11519,7 +12387,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "Discord EventQueue Max Concurrency", "help": "Optional Discord EventQueue concurrency override (max concurrent handler executions). Set per account via channels.discord.accounts..eventQueue.maxConcurrency.", "hasChildren": false @@ -11531,7 +12403,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "Discord EventQueue Max Queue Size", "help": "Optional Discord EventQueue capacity override (max queued events before backpressure). Set per account via channels.discord.accounts..eventQueue.maxQueueSize.", "hasChildren": false @@ -11579,7 +12455,10 @@ { "path": "channels.discord.execApprovals.approvers.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -11631,7 +12510,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["dm", "channel", "both"], + "enumValues": [ + "dm", + "channel", + "both" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -11642,7 +12525,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -11702,9 +12589,17 @@ { "path": "channels.discord.guilds.*.channels.*.autoArchiveDuration", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, - "enumValues": ["60", "1440", "4320", "10080"], + "enumValues": [ + "60", + "1440", + "4320", + "10080" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -11773,7 +12668,10 @@ { "path": "channels.discord.guilds.*.channels.*.roles.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -11973,7 +12871,10 @@ { "path": "channels.discord.guilds.*.channels.*.users.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -11995,7 +12896,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "own", "all", "allowlist"], + "enumValues": [ + "off", + "own", + "all", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -12024,7 +12930,10 @@ { "path": "channels.discord.guilds.*.roles.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -12204,7 +13113,10 @@ { "path": "channels.discord.guilds.*.users.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -12298,7 +13210,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "Discord Inbound Worker Timeout (ms)", "help": "Optional queued Discord inbound worker timeout in ms. This is separate from Carbon listener timeouts; defaults to 1800000 and can be disabled with 0. Set per account via channels.discord.accounts..inboundWorker.runTimeoutMs.", "hasChildren": false @@ -12320,7 +13236,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Guild Members Intent", "help": "Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.", "hasChildren": false @@ -12332,7 +13251,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Presence Intent", "help": "Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.", "hasChildren": false @@ -12352,7 +13274,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -12365,7 +13291,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "Discord Max Lines Per Message", "help": "Soft max line count per Discord message (default: 17).", "hasChildren": false @@ -12407,7 +13337,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord PluralKit Enabled", "help": "Resolve PluralKit proxied messages and treat system members as distinct senders.", "hasChildren": false @@ -12415,11 +13348,19 @@ { "path": "channels.discord.pluralkit.token", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "label": "Discord PluralKit Token", "help": "Optional PluralKit token for resolving private systems or members.", "hasChildren": true @@ -12461,7 +13402,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Proxy URL", "help": "Proxy URL for Discord gateway + API requests (app-id lookup and allowlist resolution). Set per account via channels.discord.accounts..proxy.", "hasChildren": false @@ -12503,7 +13447,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "reliability"], + "tags": [ + "channels", + "network", + "reliability" + ], "label": "Discord Retry Attempts", "help": "Max retry attempts for outbound Discord API calls (default: 3).", "hasChildren": false @@ -12515,7 +13463,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "reliability"], + "tags": [ + "channels", + "network", + "reliability" + ], "label": "Discord Retry Jitter", "help": "Jitter factor (0-1) applied to Discord retry delays.", "hasChildren": false @@ -12527,7 +13479,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance", "reliability"], + "tags": [ + "channels", + "network", + "performance", + "reliability" + ], "label": "Discord Retry Max Delay (ms)", "help": "Maximum retry delay cap in ms for Discord outbound calls.", "hasChildren": false @@ -12539,7 +13496,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "reliability"], + "tags": [ + "channels", + "network", + "reliability" + ], "label": "Discord Retry Min Delay (ms)", "help": "Minimum retry delay in ms for Discord outbound calls.", "hasChildren": false @@ -12569,10 +13530,18 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["online", "dnd", "idle", "invisible"], + "enumValues": [ + "online", + "dnd", + "idle", + "invisible" + ], "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Presence Status", "help": "Discord presence status (online, dnd, idle, invisible).", "hasChildren": false @@ -12580,12 +13549,23 @@ { "path": "channels.discord.streaming", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, - "enumValues": ["off", "partial", "block", "progress"], + "enumValues": [ + "off", + "partial", + "block", + "progress" + ], "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Streaming Mode", "help": "Unified Discord stream preview mode: \"off\" | \"partial\" | \"block\" | \"progress\". \"progress\" maps to \"partial\" on Discord. Legacy boolean/streamMode keys are auto-mapped.", "hasChildren": false @@ -12595,10 +13575,17 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["partial", "block", "off"], + "enumValues": [ + "partial", + "block", + "off" + ], "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Stream Mode (Legacy)", "help": "Legacy Discord preview mode alias (off | partial | block); auto-migrated to channels.discord.streaming.", "hasChildren": false @@ -12630,7 +13617,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "storage"], + "tags": [ + "channels", + "network", + "storage" + ], "label": "Discord Thread Binding Enabled", "help": "Enable Discord thread binding features (/focus, bound-thread routing/delivery, and thread-bound subagent sessions). Overrides session.threadBindings.enabled when set.", "hasChildren": false @@ -12642,7 +13633,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "storage"], + "tags": [ + "channels", + "network", + "storage" + ], "label": "Discord Thread Binding Idle Timeout (hours)", "help": "Inactivity window in hours for Discord thread-bound sessions (/focus and spawned thread sessions). Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set.", "hasChildren": false @@ -12654,7 +13649,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance", "storage"], + "tags": [ + "channels", + "network", + "performance", + "storage" + ], "label": "Discord Thread Binding Max Age (hours)", "help": "Optional hard max age in hours for Discord thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.", "hasChildren": false @@ -12666,7 +13666,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "storage"], + "tags": [ + "channels", + "network", + "storage" + ], "label": "Discord Thread-Bound ACP Spawn", "help": "Allow /acp spawn to auto-create and bind Discord threads for ACP sessions (default: false; opt-in). Set true to enable thread-bound ACP spawns for this account/channel.", "hasChildren": false @@ -12678,7 +13682,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "storage"], + "tags": [ + "channels", + "network", + "storage" + ], "label": "Discord Thread-Bound Subagent Spawn", "help": "Allow subagent spawns with thread=true to auto-create and bind Discord threads (default: false; opt-in). Set true to enable thread-bound subagent spawns for this account/channel.", "hasChildren": false @@ -12686,11 +13694,19 @@ { "path": "channels.discord.token", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "label": "Discord Bot Token", "help": "Discord bot token used for gateway and REST API authentication for this provider account. Keep this secret out of committed config and rotate immediately after any leak.", "hasChildren": true @@ -12752,7 +13768,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Component Accent Color", "help": "Accent color for Discord component containers (hex). Set per account via channels.discord.accounts..ui.components.accentColor.", "hasChildren": false @@ -12774,7 +13793,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Voice Auto-Join", "help": "Voice channels to auto-join on startup (list of guildId/channelId entries).", "hasChildren": true @@ -12816,7 +13838,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Voice DAVE Encryption", "help": "Toggle DAVE end-to-end encryption for Discord voice joins (default: true in @discordjs/voice; Discord may require this).", "hasChildren": false @@ -12828,7 +13853,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Voice Decrypt Failure Tolerance", "help": "Consecutive decrypt failures before DAVE attempts session recovery (passed to @discordjs/voice; default: 24).", "hasChildren": false @@ -12840,7 +13868,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Discord Voice Enabled", "help": "Enable Discord voice channel conversations (default: true). Omit channels.discord.voice to keep voice support disabled for the account.", "hasChildren": false @@ -12852,7 +13883,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "media", "network"], + "tags": [ + "channels", + "media", + "network" + ], "label": "Discord Voice Text-to-Speech", "help": "Optional TTS overrides for Discord voice playback (merged with messages.tts).", "hasChildren": true @@ -12862,7 +13897,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "always", "inbound", "tagged"], + "enumValues": [ + "off", + "always", + "inbound", + "tagged" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -12991,11 +14031,20 @@ { "path": "channels.discord.voice.tts.elevenlabs.apiKey", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "media", "network", "security"], + "tags": [ + "auth", + "channels", + "media", + "network", + "security" + ], "hasChildren": true }, { @@ -13033,7 +14082,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["auto", "on", "off"], + "enumValues": [ + "auto", + "on", + "off" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -13174,7 +14227,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["final", "all"], + "enumValues": [ + "final", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -13283,11 +14339,20 @@ { "path": "channels.discord.voice.tts.openai.apiKey", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "media", "network", "security"], + "tags": [ + "auth", + "channels", + "media", + "network", + "security" + ], "hasChildren": true }, { @@ -13385,7 +14450,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["elevenlabs", "openai", "edge"], + "enumValues": [ + "elevenlabs", + "openai", + "edge" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -13418,7 +14487,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Feishu", "help": "飞书/Lark enterprise messaging.", "hasChildren": true @@ -13476,7 +14548,10 @@ { "path": "channels.feishu.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -13496,7 +14571,10 @@ { "path": "channels.feishu.accounts.*.appSecret", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -13598,7 +14676,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -13619,7 +14700,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["websocket", "webhook"], + "enumValues": [ + "websocket", + "webhook" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -13640,7 +14724,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "pairing", "allowlist"], + "enumValues": [ + "open", + "pairing", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -13691,7 +14779,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["feishu", "lark"], + "enumValues": [ + "feishu", + "lark" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -13710,7 +14801,10 @@ { "path": "channels.feishu.accounts.*.encryptKey", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -13760,7 +14854,10 @@ { "path": "channels.feishu.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -13772,7 +14869,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "allowlist", "disabled"], + "enumValues": [ + "open", + "allowlist", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -13811,7 +14912,10 @@ { "path": "channels.feishu.accounts.*.groups.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -13833,7 +14937,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["group", "group_sender", "group_topic", "group_topic_sender"], + "enumValues": [ + "group", + "group_sender", + "group_topic", + "group_topic_sender" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -13844,7 +14953,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["disabled", "enabled"], + "enumValues": [ + "disabled", + "enabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -13945,7 +15057,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["disabled", "enabled"], + "enumValues": [ + "disabled", + "enabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -13964,7 +15079,10 @@ { "path": "channels.feishu.accounts.*.groupSenderAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -13976,7 +15094,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["group", "group_sender", "group_topic", "group_topic_sender"], + "enumValues": [ + "group", + "group_sender", + "group_topic", + "group_topic_sender" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14007,7 +15130,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["visible", "hidden"], + "enumValues": [ + "visible", + "hidden" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14048,7 +15174,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["native", "escape", "strip"], + "enumValues": [ + "native", + "escape", + "strip" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14059,7 +15189,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["native", "ascii", "simple"], + "enumValues": [ + "native", + "ascii", + "simple" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14090,7 +15224,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "own", "all"], + "enumValues": [ + "off", + "own", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14101,7 +15239,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["auto", "raw", "card"], + "enumValues": [ + "auto", + "raw", + "card" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14112,7 +15254,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["disabled", "enabled"], + "enumValues": [ + "disabled", + "enabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14233,7 +15378,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["disabled", "enabled"], + "enumValues": [ + "disabled", + "enabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14252,7 +15400,10 @@ { "path": "channels.feishu.accounts.*.verificationToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -14352,7 +15503,10 @@ { "path": "channels.feishu.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -14372,7 +15526,10 @@ { "path": "channels.feishu.appSecret", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -14474,7 +15631,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14495,7 +15655,10 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["websocket", "webhook"], + "enumValues": [ + "websocket", + "webhook" + ], "defaultValue": "websocket", "deprecated": false, "sensitive": false, @@ -14527,7 +15690,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "pairing", "allowlist"], + "enumValues": [ + "open", + "pairing", + "allowlist" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -14579,7 +15746,10 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["feishu", "lark"], + "enumValues": [ + "feishu", + "lark" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14648,7 +15818,10 @@ { "path": "channels.feishu.encryptKey", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -14698,7 +15871,10 @@ { "path": "channels.feishu.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -14710,7 +15886,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "allowlist", "disabled"], + "enumValues": [ + "open", + "allowlist", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14749,7 +15929,10 @@ { "path": "channels.feishu.groups.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -14771,7 +15954,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["group", "group_sender", "group_topic", "group_topic_sender"], + "enumValues": [ + "group", + "group_sender", + "group_topic", + "group_topic_sender" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14782,7 +15970,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["disabled", "enabled"], + "enumValues": [ + "disabled", + "enabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14883,7 +16074,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["disabled", "enabled"], + "enumValues": [ + "disabled", + "enabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14902,7 +16096,10 @@ { "path": "channels.feishu.groupSenderAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -14914,7 +16111,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["group", "group_sender", "group_topic", "group_topic_sender"], + "enumValues": [ + "group", + "group_sender", + "group_topic", + "group_topic_sender" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14945,7 +16147,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["visible", "hidden"], + "enumValues": [ + "visible", + "hidden" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14986,7 +16191,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["native", "escape", "strip"], + "enumValues": [ + "native", + "escape", + "strip" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -14997,7 +16206,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["native", "ascii", "simple"], + "enumValues": [ + "native", + "ascii", + "simple" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -15018,7 +16231,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["off", "own", "all"], + "enumValues": [ + "off", + "own", + "all" + ], "defaultValue": "own", "deprecated": false, "sensitive": false, @@ -15030,7 +16247,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["auto", "raw", "card"], + "enumValues": [ + "auto", + "raw", + "card" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -15041,7 +16262,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["disabled", "enabled"], + "enumValues": [ + "disabled", + "enabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -15164,7 +16388,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["disabled", "enabled"], + "enumValues": [ + "disabled", + "enabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -15184,7 +16411,10 @@ { "path": "channels.feishu.verificationToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -15259,7 +16489,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Google Chat", "help": "Google Workspace Chat app with HTTP webhook.", "hasChildren": true @@ -15339,7 +16572,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["app-url", "project-number"], + "enumValues": [ + "app-url", + "project-number" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -15430,7 +16666,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -15489,7 +16728,10 @@ { "path": "channels.googlechat.accounts.*.dm.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -15511,7 +16753,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -15581,7 +16828,10 @@ { "path": "channels.googlechat.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -15593,7 +16843,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -15673,7 +16927,10 @@ { "path": "channels.googlechat.accounts.*.groups.*.users.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -15763,11 +17020,18 @@ { "path": "channels.googlechat.accounts.*.serviceAccount", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["channels", "network", "security"], + "tags": [ + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -15826,7 +17090,11 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["channels", "network", "security"], + "tags": [ + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -15864,7 +17132,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["replace", "status_final", "append"], + "enumValues": [ + "replace", + "status_final", + "append" + ], "defaultValue": "replace", "deprecated": false, "sensitive": false, @@ -15886,7 +17158,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["none", "message", "reaction"], + "enumValues": [ + "none", + "message", + "reaction" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -15967,7 +17243,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["app-url", "project-number"], + "enumValues": [ + "app-url", + "project-number" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -16058,7 +17337,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -16127,7 +17409,10 @@ { "path": "channels.googlechat.dm.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -16149,7 +17434,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -16219,7 +17509,10 @@ { "path": "channels.googlechat.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -16231,7 +17524,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -16311,7 +17608,10 @@ { "path": "channels.googlechat.groups.*.users.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -16401,11 +17701,18 @@ { "path": "channels.googlechat.serviceAccount", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["channels", "network", "security"], + "tags": [ + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -16464,7 +17771,11 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["channels", "network", "security"], + "tags": [ + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -16502,7 +17813,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["replace", "status_final", "append"], + "enumValues": [ + "replace", + "status_final", + "append" + ], "defaultValue": "replace", "deprecated": false, "sensitive": false, @@ -16524,7 +17839,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["none", "message", "reaction"], + "enumValues": [ + "none", + "message", + "reaction" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -16557,7 +17876,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "iMessage", "help": "this is still a work in progress.", "hasChildren": true @@ -16595,7 +17917,10 @@ { "path": "channels.imessage.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -16697,7 +18022,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -16758,7 +18086,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -16818,7 +18151,10 @@ { "path": "channels.imessage.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -16830,7 +18166,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -17112,7 +18452,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -17221,7 +18565,10 @@ { "path": "channels.imessage.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -17323,7 +18670,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -17336,7 +18686,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "storage"], + "tags": [ + "channels", + "network", + "storage" + ], "label": "iMessage CLI Path", "help": "Filesystem path to the iMessage bridge CLI binary used for send/receive operations. Set explicitly when the binary is not on PATH in service runtime environments.", "hasChildren": false @@ -17348,7 +18702,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "iMessage Config Writes", "help": "Allow iMessage to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -17398,11 +18755,20 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "iMessage DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.imessage.allowFrom=[\"*\"].", "hasChildren": false @@ -17460,7 +18826,10 @@ { "path": "channels.imessage.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -17472,7 +18841,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -17754,7 +19127,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -17857,7 +19234,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "IRC", "help": "classic IRC networks with DM/channel routing and pairing controls.", "hasChildren": true @@ -17895,7 +19275,10 @@ { "path": "channels.irc.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -17977,7 +19360,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -18008,7 +19394,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -18068,7 +19459,10 @@ { "path": "channels.irc.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -18080,7 +19474,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -18120,7 +19518,10 @@ { "path": "channels.irc.accounts.*.groups.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -18362,7 +19763,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -18445,7 +19850,12 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": false }, { @@ -18495,7 +19905,12 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": false }, { @@ -18581,7 +19996,10 @@ { "path": "channels.irc.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -18663,7 +20081,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -18704,11 +20125,20 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "IRC DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.irc.allowFrom=[\"*\"].", "hasChildren": false @@ -18766,7 +20196,10 @@ { "path": "channels.irc.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -18778,7 +20211,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -18818,7 +20255,10 @@ { "path": "channels.irc.groups.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -19060,7 +20500,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -19133,7 +20577,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "IRC NickServ Enabled", "help": "Enable NickServ identify/register after connect (defaults to enabled when password is configured).", "hasChildren": false @@ -19145,7 +20592,12 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "label": "IRC NickServ Password", "help": "NickServ password used for IDENTIFY/REGISTER (sensitive).", "hasChildren": false @@ -19157,7 +20609,13 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["auth", "channels", "network", "security", "storage"], + "tags": [ + "auth", + "channels", + "network", + "security", + "storage" + ], "label": "IRC NickServ Password File", "help": "Optional file path containing NickServ password.", "hasChildren": false @@ -19169,7 +20627,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "IRC NickServ Register", "help": "If true, send NickServ REGISTER on every connect. Use once for initial registration, then disable.", "hasChildren": false @@ -19181,7 +20642,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "IRC NickServ Register Email", "help": "Email used with NickServ REGISTER (required when register=true).", "hasChildren": false @@ -19193,7 +20657,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "IRC NickServ Service", "help": "NickServ service nick (default: NickServ).", "hasChildren": false @@ -19205,7 +20672,12 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": false }, { @@ -19285,7 +20757,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "LINE", "help": "LINE Messaging API bot for Japan/Taiwan/Thailand markets.", "hasChildren": true @@ -19323,7 +20798,10 @@ { "path": "channels.line.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -19355,7 +20833,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "allowlist", "pairing", "disabled"], + "enumValues": [ + "open", + "allowlist", + "pairing", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -19385,7 +20868,10 @@ { "path": "channels.line.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -19397,7 +20883,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "allowlist", "disabled"], + "enumValues": [ + "open", + "allowlist", + "disabled" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -19437,7 +20927,10 @@ { "path": "channels.line.accounts.*.groups.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -19567,7 +21060,10 @@ { "path": "channels.line.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -19609,7 +21105,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "allowlist", "pairing", "disabled"], + "enumValues": [ + "open", + "allowlist", + "pairing", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -19639,7 +21140,10 @@ { "path": "channels.line.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -19651,7 +21155,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "allowlist", "disabled"], + "enumValues": [ + "open", + "allowlist", + "disabled" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -19691,7 +21199,10 @@ { "path": "channels.line.groups.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -19815,7 +21326,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Matrix", "help": "open protocol; configure a homeserver + access token.", "hasChildren": true @@ -19924,7 +21438,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["always", "allowlist", "off"], + "enumValues": [ + "always", + "allowlist", + "off" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -19943,7 +21461,10 @@ { "path": "channels.matrix.autoJoinAllowlist.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -19955,7 +21476,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -20004,7 +21528,10 @@ { "path": "channels.matrix.dm.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -20026,7 +21553,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -20065,7 +21597,10 @@ { "path": "channels.matrix.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -20077,7 +21612,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -20256,7 +21795,10 @@ { "path": "channels.matrix.groups.*.users.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -20298,7 +21840,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -20327,7 +21873,10 @@ { "path": "channels.matrix.password", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -20369,7 +21918,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "first", "all"], + "enumValues": [ + "off", + "first", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -20558,7 +22111,10 @@ { "path": "channels.matrix.rooms.*.users.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -20580,7 +22136,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "inbound", "always"], + "enumValues": [ + "off", + "inbound", + "always" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -20603,7 +22163,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Mattermost", "help": "self-hosted Slack-style chat; install the plugin to enable.", "hasChildren": true @@ -20661,7 +22224,10 @@ { "path": "channels.mattermost.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -20731,7 +22297,10 @@ { "path": "channels.mattermost.accounts.*.botToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -20793,7 +22362,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["oncall", "onmessage", "onchar"], + "enumValues": [ + "oncall", + "onmessage", + "onchar" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -20804,7 +22377,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -20843,7 +22419,10 @@ { "path": "channels.mattermost.accounts.*.commands.native", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -20853,7 +22432,10 @@ { "path": "channels.mattermost.accounts.*.commands.nativeSkills", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -20885,7 +22467,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -20915,7 +22502,10 @@ { "path": "channels.mattermost.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -20927,7 +22517,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -20989,7 +22583,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -21030,7 +22628,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "first", "all"], + "enumValues": [ + "off", + "first", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -21099,7 +22701,10 @@ { "path": "channels.mattermost.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -21113,7 +22718,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Mattermost Base URL", "help": "Base URL for your Mattermost server (e.g., https://chat.example.com).", "hasChildren": false @@ -21171,11 +22779,19 @@ { "path": "channels.mattermost.botToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "label": "Mattermost Bot Token", "help": "Bot token from Mattermost System Console -> Integrations -> Bot Accounts.", "hasChildren": true @@ -21235,10 +22851,17 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["oncall", "onmessage", "onchar"], + "enumValues": [ + "oncall", + "onmessage", + "onchar" + ], "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Mattermost Chat Mode", "help": "Reply to channel messages on mention (\"oncall\"), on trigger chars (\">\" or \"!\") (\"onchar\"), or on every message (\"onmessage\").", "hasChildren": false @@ -21248,7 +22871,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -21287,7 +22913,10 @@ { "path": "channels.mattermost.commands.native", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -21297,7 +22926,10 @@ { "path": "channels.mattermost.commands.nativeSkills", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -21311,7 +22943,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Mattermost Config Writes", "help": "Allow Mattermost to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -21341,7 +22976,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -21371,7 +23011,10 @@ { "path": "channels.mattermost.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -21383,7 +23026,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -21445,7 +23092,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -21468,7 +23119,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Mattermost Onchar Prefixes", "help": "Trigger prefixes for onchar mode (default: [\">\", \"!\"]).", "hasChildren": true @@ -21488,7 +23142,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "first", "all"], + "enumValues": [ + "off", + "first", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -21501,7 +23159,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Mattermost Require Mention", "help": "Require @mention in channels before responding (default: true).", "hasChildren": false @@ -21533,7 +23194,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Microsoft Teams", "help": "Bot Framework; enterprise support.", "hasChildren": true @@ -21571,11 +23235,19 @@ { "path": "channels.msteams.appPassword", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -21673,7 +23345,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -21686,7 +23361,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "MS Teams Config Writes", "help": "Allow Microsoft Teams to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -21726,7 +23404,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -21798,7 +23481,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -21890,7 +23577,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -21951,7 +23642,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["thread", "top-level"], + "enumValues": [ + "thread", + "top-level" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -22032,7 +23726,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["thread", "top-level"], + "enumValues": [ + "thread", + "top-level" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -22203,7 +23900,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["thread", "top-level"], + "enumValues": [ + "thread", + "top-level" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -22426,7 +24126,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Nextcloud Talk", "help": "Self-hosted chat via Nextcloud Talk webhook bots.", "hasChildren": true @@ -22474,7 +24177,10 @@ { "path": "channels.nextcloud-talk.accounts.*.apiPassword", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -22594,7 +24300,10 @@ { "path": "channels.nextcloud-talk.accounts.*.botSecret", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -22646,7 +24355,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -22667,7 +24379,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -22739,7 +24456,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -22771,7 +24492,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -23040,7 +24765,10 @@ { "path": "channels.nextcloud-talk.apiPassword", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -23160,7 +24888,10 @@ { "path": "channels.nextcloud-talk.botSecret", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -23212,7 +24943,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -23243,7 +24977,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -23315,7 +25054,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -23347,7 +25090,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -23600,7 +25347,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Nostr", "help": "Decentralized DMs via Nostr relays (NIP-04)", "hasChildren": true @@ -23618,7 +25368,10 @@ { "path": "channels.nostr.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -23640,7 +25393,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -23671,7 +25429,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -23814,7 +25576,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Signal", "help": "signal-cli linked device; more setup (David Reagans: \"Hop on Discord.\").", "hasChildren": true @@ -23826,7 +25591,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Signal Account", "help": "Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state.", "hasChildren": false @@ -23904,7 +25672,10 @@ { "path": "channels.signal.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -23996,7 +25767,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -24047,7 +25821,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -24107,7 +25886,10 @@ { "path": "channels.signal.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -24119,7 +25901,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -24441,7 +26227,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -24480,7 +26270,10 @@ { "path": "channels.signal.accounts.*.reactionAllowlist.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -24492,7 +26285,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "ack", "minimal", "extensive"], + "enumValues": [ + "off", + "ack", + "minimal", + "extensive" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -24503,7 +26301,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "own", "all", "allowlist"], + "enumValues": [ + "off", + "own", + "all", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -24602,7 +26405,10 @@ { "path": "channels.signal.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -24694,7 +26500,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -24717,7 +26526,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Signal Config Writes", "help": "Allow Signal to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -24757,11 +26569,20 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "Signal DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.signal.allowFrom=[\"*\"].", "hasChildren": false @@ -24819,7 +26640,10 @@ { "path": "channels.signal.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -24831,7 +26655,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -25153,7 +26981,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -25192,7 +27024,10 @@ { "path": "channels.signal.reactionAllowlist.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -25204,7 +27039,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "ack", "minimal", "extensive"], + "enumValues": [ + "off", + "ack", + "minimal", + "extensive" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -25215,7 +27055,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "own", "all", "allowlist"], + "enumValues": [ + "off", + "own", + "all", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -25278,7 +27123,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Slack", "help": "supported (Socket Mode).", "hasChildren": true @@ -25426,7 +27274,10 @@ { "path": "channels.slack.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -25436,11 +27287,19 @@ { "path": "channels.slack.accounts.*.appToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -25526,11 +27385,19 @@ { "path": "channels.slack.accounts.*.botToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -25566,7 +27433,10 @@ { "path": "channels.slack.accounts.*.capabilities", "kind": "channel", - "type": ["array", "object"], + "type": [ + "array", + "object" + ], "required": false, "deprecated": false, "sensitive": false, @@ -25846,7 +27716,10 @@ { "path": "channels.slack.accounts.*.channels.*.users.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -25858,7 +27731,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -25877,7 +27753,10 @@ { "path": "channels.slack.accounts.*.commands.native", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -25887,7 +27766,10 @@ { "path": "channels.slack.accounts.*.commands.nativeSkills", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -25947,7 +27829,10 @@ { "path": "channels.slack.accounts.*.dm.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -25977,7 +27862,10 @@ { "path": "channels.slack.accounts.*.dm.groupChannels.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -25999,7 +27887,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -26030,7 +27923,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -26081,7 +27979,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -26172,7 +28074,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -26193,7 +28099,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["socket", "http"], + "enumValues": [ + "socket", + "http" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -26232,7 +28141,10 @@ { "path": "channels.slack.accounts.*.reactionAllowlist.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -26244,7 +28156,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "own", "all", "allowlist"], + "enumValues": [ + "off", + "own", + "all", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -26323,11 +28240,19 @@ { "path": "channels.slack.accounts.*.signingSecret", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -26413,9 +28338,17 @@ { "path": "channels.slack.accounts.*.streaming", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, - "enumValues": ["off", "partial", "block", "progress"], + "enumValues": [ + "off", + "partial", + "block", + "progress" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -26426,7 +28359,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["replace", "status_final", "append"], + "enumValues": [ + "replace", + "status_final", + "append" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -26457,7 +28394,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["thread", "channel"], + "enumValues": [ + "thread", + "channel" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -26496,11 +28436,19 @@ { "path": "channels.slack.accounts.*.userToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -26661,7 +28609,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "Slack Allow Bot Messages", "help": "Allow bot-authored messages to trigger Slack replies (default: false).", "hasChildren": false @@ -26679,7 +28631,10 @@ { "path": "channels.slack.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -26689,11 +28644,19 @@ { "path": "channels.slack.appToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "label": "Slack App Token", "help": "Slack app-level token used for Socket Mode connections and event transport when enabled. Use least-privilege app scopes and store this token as a secret.", "hasChildren": true @@ -26781,11 +28744,19 @@ { "path": "channels.slack.botToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "label": "Slack Bot Token", "help": "Slack bot token used for standard chat actions in the configured workspace. Keep this credential scoped and rotate if workspace app permissions change.", "hasChildren": true @@ -26823,7 +28794,10 @@ { "path": "channels.slack.capabilities", "kind": "channel", - "type": ["array", "object"], + "type": [ + "array", + "object" + ], "required": false, "deprecated": false, "sensitive": false, @@ -26847,7 +28821,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Slack Interactive Replies", "help": "Enable agent-authored Slack interactive reply directives (`[[slack_buttons: ...]]`, `[[slack_select: ...]]`). Default: false.", "hasChildren": false @@ -27105,7 +29082,10 @@ { "path": "channels.slack.channels.*.users.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -27117,7 +29097,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -27136,11 +29119,17 @@ { "path": "channels.slack.commands.native", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Slack Native Commands", "help": "Override native commands for Slack (bool or \"auto\").", "hasChildren": false @@ -27148,11 +29137,17 @@ { "path": "channels.slack.commands.nativeSkills", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Slack Native Skill Commands", "help": "Override native skill commands for Slack (bool or \"auto\").", "hasChildren": false @@ -27164,7 +29159,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Slack Config Writes", "help": "Allow Slack to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -27222,7 +29220,10 @@ { "path": "channels.slack.dm.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -27252,7 +29253,10 @@ { "path": "channels.slack.dm.groupChannels.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -27274,10 +29278,19 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "Slack DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.slack.allowFrom=[\"*\"] (legacy: channels.slack.dm.allowFrom).", "hasChildren": false @@ -27307,10 +29320,19 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "Slack DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.slack.allowFrom=[\"*\"].", "hasChildren": false @@ -27360,7 +29382,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -27452,7 +29478,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -27473,7 +29503,10 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["socket", "http"], + "enumValues": [ + "socket", + "http" + ], "defaultValue": "socket", "deprecated": false, "sensitive": false, @@ -27497,7 +29530,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Slack Native Streaming", "help": "Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming is partial (default: true).", "hasChildren": false @@ -27515,7 +29551,10 @@ { "path": "channels.slack.reactionAllowlist.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -27527,7 +29566,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "own", "all", "allowlist"], + "enumValues": [ + "off", + "own", + "all", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -27606,11 +29650,19 @@ { "path": "channels.slack.signingSecret", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -27696,12 +29748,23 @@ { "path": "channels.slack.streaming", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, - "enumValues": ["off", "partial", "block", "progress"], + "enumValues": [ + "off", + "partial", + "block", + "progress" + ], "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Slack Streaming Mode", "help": "Unified Slack stream preview mode: \"off\" | \"partial\" | \"block\" | \"progress\". Legacy boolean/streamMode keys are auto-mapped.", "hasChildren": false @@ -27711,10 +29774,17 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["replace", "status_final", "append"], + "enumValues": [ + "replace", + "status_final", + "append" + ], "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Slack Stream Mode (Legacy)", "help": "Legacy Slack preview mode alias (replace | status_final | append); auto-migrated to channels.slack.streaming.", "hasChildren": false @@ -27744,10 +29814,16 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["thread", "channel"], + "enumValues": [ + "thread", + "channel" + ], "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Slack Thread History Scope", "help": "Scope for Slack thread history context (\"thread\" isolates per thread; \"channel\" reuses channel history).", "hasChildren": false @@ -27759,7 +29835,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Slack Thread Parent Inheritance", "help": "If true, Slack thread sessions inherit the parent channel transcript (default: false).", "hasChildren": false @@ -27771,7 +29850,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "Slack Thread Initial History Limit", "help": "Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable).", "hasChildren": false @@ -27789,11 +29872,19 @@ { "path": "channels.slack.userToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "label": "Slack User Token", "help": "Optional Slack user token for workflows requiring user-context API access beyond bot permissions. Use sparingly and audit scopes because this token can carry broader authority.", "hasChildren": true @@ -27836,7 +29927,12 @@ "defaultValue": true, "deprecated": false, "sensitive": false, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "label": "Slack User Token Read Only", "help": "When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes.", "hasChildren": false @@ -27859,7 +29955,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Synology Chat", "help": "Connect your Synology NAS Chat to OpenClaw", "hasChildren": true @@ -27880,7 +29979,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram", "help": "simplest way to get started — register a bot with @BotFather and get going.", "hasChildren": true @@ -28008,7 +30110,10 @@ { "path": "channels.telegram.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -28068,11 +30173,19 @@ { "path": "channels.telegram.accounts.*.botToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -28108,7 +30221,10 @@ { "path": "channels.telegram.accounts.*.capabilities", "kind": "channel", - "type": ["array", "object"], + "type": [ + "array", + "object" + ], "required": false, "deprecated": false, "sensitive": false, @@ -28130,7 +30246,13 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "dm", "group", "all", "allowlist"], + "enumValues": [ + "off", + "dm", + "group", + "all", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -28141,7 +30263,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -28160,7 +30285,10 @@ { "path": "channels.telegram.accounts.*.commands.native", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -28170,7 +30298,10 @@ { "path": "channels.telegram.accounts.*.commands.nativeSkills", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -28230,7 +30361,10 @@ { "path": "channels.telegram.accounts.*.defaultTo", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -28270,7 +30404,10 @@ { "path": "channels.telegram.accounts.*.direct.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -28282,7 +30419,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -28531,7 +30673,10 @@ { "path": "channels.telegram.accounts.*.direct.*.topics.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -28563,7 +30708,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -28624,7 +30773,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -28754,7 +30908,10 @@ { "path": "channels.telegram.accounts.*.execApprovals.approvers.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -28796,7 +30953,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["dm", "channel", "both"], + "enumValues": [ + "dm", + "channel", + "both" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -28815,7 +30976,10 @@ { "path": "channels.telegram.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -28827,7 +30991,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -28867,7 +31035,10 @@ { "path": "channels.telegram.accounts.*.groups.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -28899,7 +31070,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -29138,7 +31313,10 @@ { "path": "channels.telegram.accounts.*.groups.*.topics.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -29170,7 +31348,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -29311,7 +31493,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -29362,7 +31548,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["ipv4first", "verbatim"], + "enumValues": [ + "ipv4first", + "verbatim" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -29383,7 +31572,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "ack", "minimal", "extensive"], + "enumValues": [ + "off", + "ack", + "minimal", + "extensive" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -29394,7 +31588,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "own", "all"], + "enumValues": [ + "off", + "own", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -29473,9 +31671,17 @@ { "path": "channels.telegram.accounts.*.streaming", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, - "enumValues": ["off", "partial", "block", "progress"], + "enumValues": [ + "off", + "partial", + "block", + "progress" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -29486,7 +31692,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "partial", "block"], + "enumValues": [ + "off", + "partial", + "block" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -29625,11 +31835,19 @@ { "path": "channels.telegram.accounts.*.webhookSecret", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -29775,7 +31993,10 @@ { "path": "channels.telegram.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -29835,11 +32056,19 @@ { "path": "channels.telegram.botToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "label": "Telegram Bot Token", "help": "Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected.", "hasChildren": true @@ -29877,7 +32106,10 @@ { "path": "channels.telegram.capabilities", "kind": "channel", - "type": ["array", "object"], + "type": [ + "array", + "object" + ], "required": false, "deprecated": false, "sensitive": false, @@ -29899,10 +32131,19 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "dm", "group", "all", "allowlist"], + "enumValues": [ + "off", + "dm", + "group", + "all", + "allowlist" + ], "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Inline Buttons", "help": "Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior.", "hasChildren": false @@ -29912,7 +32153,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -29931,11 +32175,17 @@ { "path": "channels.telegram.commands.native", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Native Commands", "help": "Override native commands for Telegram (bool or \"auto\").", "hasChildren": false @@ -29943,11 +32193,17 @@ { "path": "channels.telegram.commands.nativeSkills", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Native Skill Commands", "help": "Override native skill commands for Telegram (bool or \"auto\").", "hasChildren": false @@ -29959,7 +32215,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Config Writes", "help": "Allow Telegram to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -29971,7 +32230,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Custom Commands", "help": "Additional Telegram bot menu commands (merged with native; conflicts ignored).", "hasChildren": true @@ -30019,7 +32281,10 @@ { "path": "channels.telegram.defaultTo", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -30059,7 +32324,10 @@ { "path": "channels.telegram.direct.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -30071,7 +32339,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -30320,7 +32593,10 @@ { "path": "channels.telegram.direct.*.topics.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -30352,7 +32628,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -30413,11 +32693,20 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "Telegram DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.telegram.allowFrom=[\"*\"].", "hasChildren": false @@ -30509,7 +32798,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Exec Approvals", "help": "Telegram-native exec approval routing and approver authorization. Enable this only when Telegram should act as an explicit exec-approval client for the selected bot account.", "hasChildren": true @@ -30521,7 +32813,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Exec Approval Agent Filter", "help": "Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `[\"main\", \"ops-agent\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram.", "hasChildren": true @@ -30543,7 +32838,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Exec Approval Approvers", "help": "Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs; prompts are only delivered to these approvers when target includes dm.", "hasChildren": true @@ -30551,7 +32849,10 @@ { "path": "channels.telegram.execApprovals.approvers.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -30565,7 +32866,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Exec Approvals Enabled", "help": "Enable Telegram exec approvals for this account. When false or unset, Telegram messages/buttons cannot approve exec requests.", "hasChildren": false @@ -30577,7 +32881,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "storage"], + "tags": [ + "channels", + "network", + "storage" + ], "label": "Telegram Exec Approval Session Filter", "help": "Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions.", "hasChildren": true @@ -30597,10 +32905,17 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["dm", "channel", "both"], + "enumValues": [ + "dm", + "channel", + "both" + ], "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Exec Approval Target", "help": "Controls where Telegram approval prompts are sent: \"dm\" sends to approver DMs (default), \"channel\" sends to the originating Telegram chat/topic, and \"both\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics.", "hasChildren": false @@ -30618,7 +32933,10 @@ { "path": "channels.telegram.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -30630,7 +32948,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -30670,7 +32992,10 @@ { "path": "channels.telegram.groups.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -30702,7 +33027,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -30941,7 +33270,10 @@ { "path": "channels.telegram.groups.*.topics.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -30973,7 +33305,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -31114,7 +33450,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -31157,7 +33497,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram autoSelectFamily", "help": "Override Node autoSelectFamily for Telegram (true=enable, false=disable).", "hasChildren": false @@ -31167,7 +33510,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["ipv4first", "verbatim"], + "enumValues": [ + "ipv4first", + "verbatim" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -31188,7 +33534,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "ack", "minimal", "extensive"], + "enumValues": [ + "off", + "ack", + "minimal", + "extensive" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -31199,7 +33550,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "own", "all"], + "enumValues": [ + "off", + "own", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -31242,7 +33597,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "reliability"], + "tags": [ + "channels", + "network", + "reliability" + ], "label": "Telegram Retry Attempts", "help": "Max retry attempts for outbound Telegram API calls (default: 3).", "hasChildren": false @@ -31254,7 +33613,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "reliability"], + "tags": [ + "channels", + "network", + "reliability" + ], "label": "Telegram Retry Jitter", "help": "Jitter factor (0-1) applied to Telegram retry delays.", "hasChildren": false @@ -31266,7 +33629,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance", "reliability"], + "tags": [ + "channels", + "network", + "performance", + "reliability" + ], "label": "Telegram Retry Max Delay (ms)", "help": "Maximum retry delay cap in ms for Telegram outbound calls.", "hasChildren": false @@ -31278,7 +33646,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "reliability"], + "tags": [ + "channels", + "network", + "reliability" + ], "label": "Telegram Retry Min Delay (ms)", "help": "Minimum retry delay in ms for Telegram outbound calls.", "hasChildren": false @@ -31286,12 +33658,23 @@ { "path": "channels.telegram.streaming", "kind": "channel", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, - "enumValues": ["off", "partial", "block", "progress"], + "enumValues": [ + "off", + "partial", + "block", + "progress" + ], "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Telegram Streaming Mode", "help": "Unified Telegram stream preview mode: \"off\" | \"partial\" | \"block\" | \"progress\" (default: \"partial\"). \"progress\" maps to \"partial\" on Telegram. Legacy boolean/streamMode keys are auto-mapped.", "hasChildren": false @@ -31301,7 +33684,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "partial", "block"], + "enumValues": [ + "off", + "partial", + "block" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -31334,7 +33721,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "storage"], + "tags": [ + "channels", + "network", + "storage" + ], "label": "Telegram Thread Binding Enabled", "help": "Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set.", "hasChildren": false @@ -31346,7 +33737,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "storage"], + "tags": [ + "channels", + "network", + "storage" + ], "label": "Telegram Thread Binding Idle Timeout (hours)", "help": "Inactivity window in hours for Telegram bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set.", "hasChildren": false @@ -31358,7 +33753,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance", "storage"], + "tags": [ + "channels", + "network", + "performance", + "storage" + ], "label": "Telegram Thread Binding Max Age (hours)", "help": "Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.", "hasChildren": false @@ -31370,7 +33770,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "storage"], + "tags": [ + "channels", + "network", + "storage" + ], "label": "Telegram Thread-Bound ACP Spawn", "help": "Allow ACP spawns with thread=true to auto-bind Telegram current conversations when supported.", "hasChildren": false @@ -31382,7 +33786,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "storage"], + "tags": [ + "channels", + "network", + "storage" + ], "label": "Telegram Thread-Bound Subagent Spawn", "help": "Allow subagent spawns with thread=true to auto-bind Telegram current conversations when supported.", "hasChildren": false @@ -31394,7 +33802,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "Telegram API Timeout (seconds)", "help": "Max seconds before Telegram API requests are aborted (default: 500 per grammY).", "hasChildren": false @@ -31452,11 +33864,19 @@ { "path": "channels.telegram.webhookSecret", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "channels", "network", "security"], + "tags": [ + "auth", + "channels", + "network", + "security" + ], "hasChildren": true }, { @@ -31506,7 +33926,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Tlon", "help": "Decentralized messaging on Urbit", "hasChildren": true @@ -31756,7 +34179,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["restricted", "open"], + "enumValues": [ + "restricted", + "open" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -31939,7 +34365,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Twitch", "help": "Twitch chat integration", "hasChildren": true @@ -31999,7 +34428,13 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["moderator", "owner", "vip", "subscriber", "all"], + "enumValues": [ + "moderator", + "owner", + "vip", + "subscriber", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -32068,7 +34503,10 @@ { "path": "channels.twitch.accounts.*.expiresIn", "kind": "channel", - "type": ["null", "number"], + "type": [ + "null", + "number" + ], "required": false, "deprecated": false, "sensitive": false, @@ -32140,7 +34578,13 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["moderator", "owner", "vip", "subscriber", "all"], + "enumValues": [ + "moderator", + "owner", + "vip", + "subscriber", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -32209,7 +34653,10 @@ { "path": "channels.twitch.expiresIn", "kind": "channel", - "type": ["null", "number"], + "type": [ + "null", + "number" + ], "required": false, "deprecated": false, "sensitive": false, @@ -32231,7 +34678,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["bullets", "code", "off"], + "enumValues": [ + "bullets", + "code", + "off" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -32304,7 +34755,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "WhatsApp", "help": "works with your own number; recommend a separate phone + eSIM.", "hasChildren": true @@ -32365,7 +34819,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["always", "mentions", "never"], + "enumValues": [ + "always", + "mentions", + "never" + ], "defaultValue": "mentions", "deprecated": false, "sensitive": false, @@ -32477,7 +34935,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -32529,7 +34990,12 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, @@ -32601,7 +35067,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -32873,7 +35343,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -32985,7 +35459,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["always", "mentions", "never"], + "enumValues": [ + "always", + "mentions", + "never" + ], "defaultValue": "mentions", "deprecated": false, "sensitive": false, @@ -33127,7 +35605,10 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["length", "newline"], + "enumValues": [ + "length", + "newline" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -33140,7 +35621,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "WhatsApp Config Writes", "help": "Allow WhatsApp to write config in response to channel events/commands (default: true).", "hasChildren": false @@ -33153,7 +35637,11 @@ "defaultValue": 0, "deprecated": false, "sensitive": false, - "tags": ["channels", "network", "performance"], + "tags": [ + "channels", + "network", + "performance" + ], "label": "WhatsApp Message Debounce (ms)", "help": "Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable).", "hasChildren": false @@ -33193,11 +35681,20 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "defaultValue": "pairing", "deprecated": false, "sensitive": false, - "tags": ["access", "channels", "network"], + "tags": [ + "access", + "channels", + "network" + ], "label": "WhatsApp DM Policy", "help": "Direct message access control (\"pairing\" recommended). \"open\" requires channels.whatsapp.allowFrom=[\"*\"].", "hasChildren": false @@ -33267,7 +35764,11 @@ "kind": "channel", "type": "string", "required": true, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "defaultValue": "allowlist", "deprecated": false, "sensitive": false, @@ -33539,7 +36040,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -33583,7 +36088,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "WhatsApp Self-Phone Mode", "help": "Same-phone setup (bot uses your personal WhatsApp number).", "hasChildren": false @@ -33615,7 +36123,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Zalo", "help": "Vietnam-focused messaging platform with Bot API.", "hasChildren": true @@ -33653,7 +36164,10 @@ { "path": "channels.zalo.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -33663,7 +36177,10 @@ { "path": "channels.zalo.accounts.*.botToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -33705,7 +36222,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -33734,7 +36256,10 @@ { "path": "channels.zalo.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -33746,7 +36271,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -33767,7 +36296,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -33836,7 +36369,10 @@ { "path": "channels.zalo.accounts.*.webhookSecret", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -33896,7 +36432,10 @@ { "path": "channels.zalo.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -33906,7 +36445,10 @@ { "path": "channels.zalo.botToken", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -33958,7 +36500,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -33987,7 +36534,10 @@ { "path": "channels.zalo.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -33999,7 +36549,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -34020,7 +36574,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -34089,7 +36647,10 @@ { "path": "channels.zalo.webhookSecret", "kind": "channel", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -34143,7 +36704,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["channels", "network"], + "tags": [ + "channels", + "network" + ], "label": "Zalo Personal", "help": "Zalo personal account via QR code login.", "hasChildren": true @@ -34181,7 +36745,10 @@ { "path": "channels.zalouser.accounts.*.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -34203,7 +36770,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -34232,7 +36804,10 @@ { "path": "channels.zalouser.accounts.*.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -34244,7 +36819,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -34395,7 +36974,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -34454,7 +37037,10 @@ { "path": "channels.zalouser.allowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -34486,7 +37072,12 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["pairing", "allowlist", "open", "disabled"], + "enumValues": [ + "pairing", + "allowlist", + "open", + "disabled" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -34515,7 +37106,10 @@ { "path": "channels.zalouser.groupAllowFrom.*", "kind": "channel", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -34527,7 +37121,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["open", "disabled", "allowlist"], + "enumValues": [ + "open", + "disabled", + "allowlist" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -34678,7 +37276,11 @@ "kind": "channel", "type": "string", "required": false, - "enumValues": ["off", "bullets", "code"], + "enumValues": [ + "off", + "bullets", + "code" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -34731,7 +37333,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "CLI", "help": "CLI presentation controls for local command output behavior such as banner and tagline style. Use this section to keep startup output aligned with operator preference without changing runtime behavior.", "hasChildren": true @@ -34743,7 +37347,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "CLI Banner", "help": "CLI startup banner controls for title/version line and tagline style behavior. Keep banner enabled for fast version/context checks, then tune tagline mode to your preferred noise level.", "hasChildren": true @@ -34755,7 +37361,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "CLI Banner Tagline Mode", "help": "Controls tagline style in the CLI startup banner: \"random\" (default) picks from the rotating tagline pool, \"default\" always shows the neutral default tagline, and \"off\" hides tagline text while keeping the banner version line.", "hasChildren": false @@ -34773,7 +37381,9 @@ }, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Commands", "help": "Controls chat command surfaces, owner gating, and elevated command access behavior across providers. Keep defaults unless you need stricter operator controls or broader command availability.", "hasChildren": true @@ -34785,7 +37395,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Command Elevated Access Rules", "help": "Defines elevated command allow rules by channel and sender for owner-level command surfaces. Use narrow provider-specific identities so privileged commands are not exposed to broad chat audiences.", "hasChildren": true @@ -34803,7 +37415,10 @@ { "path": "commands.allowFrom.*.*", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -34817,7 +37432,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Allow Bash Chat Command", "help": "Allow bash chat command (`!`; `/bash` alias) to run host shell commands (default: false; requires tools.elevated).", "hasChildren": false @@ -34829,7 +37446,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Bash Foreground Window (ms)", "help": "How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately).", "hasChildren": false @@ -34841,7 +37460,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Allow /config", "help": "Allow /config chat command to read/write config on disk (default: false).", "hasChildren": false @@ -34853,7 +37474,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Allow /debug", "help": "Allow /debug chat command for runtime-only overrides (default: false).", "hasChildren": false @@ -34861,11 +37484,16 @@ { "path": "commands.native", "kind": "core", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Native Commands", "help": "Registers native slash/menu commands with channels that support command registration (Discord, Slack, Telegram). Keep enabled for discoverability unless you intentionally run text-only command workflows.", "hasChildren": false @@ -34873,11 +37501,16 @@ { "path": "commands.nativeSkills", "kind": "core", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Native Skill Commands", "help": "Registers native skill commands so users can invoke skills directly from provider command menus where supported. Keep aligned with your skill policy so exposed commands match what operators expect.", "hasChildren": false @@ -34889,7 +37522,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Command Owners", "help": "Explicit owner allowlist for owner-only tools/commands. Use channel-native IDs (optionally prefixed like \"whatsapp:+15551234567\"). '*' is ignored.", "hasChildren": true @@ -34897,7 +37532,10 @@ { "path": "commands.ownerAllowFrom.*", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -34909,11 +37547,16 @@ "kind": "core", "type": "string", "required": true, - "enumValues": ["raw", "hash"], + "enumValues": [ + "raw", + "hash" + ], "defaultValue": "raw", "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Owner ID Display", "help": "Controls how owner IDs are rendered in the system prompt. Allowed values: raw, hash. Default: raw.", "hasChildren": false @@ -34925,7 +37568,11 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["access", "auth", "security"], + "tags": [ + "access", + "auth", + "security" + ], "label": "Owner ID Hash Secret", "help": "Optional secret used to HMAC hash owner IDs when ownerDisplay=hash. Prefer env substitution.", "hasChildren": false @@ -34938,7 +37585,9 @@ "defaultValue": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Allow Restart", "help": "Allow /restart and gateway restart tool actions (default: true).", "hasChildren": false @@ -34950,7 +37599,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Text Commands", "help": "Enables text-command parsing in chat input in addition to native command surfaces where available. Keep this enabled for compatibility across channels that do not support native command registration.", "hasChildren": false @@ -34962,7 +37613,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Use Access Groups", "help": "Enforce access-group allowlists/policies for commands.", "hasChildren": false @@ -34974,7 +37627,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], + "tags": [ + "automation" + ], "label": "Cron", "help": "Global scheduler settings for stored cron jobs, run concurrency, delivery fallback, and run-session retention. Keep defaults unless you are scaling job volume or integrating external webhook receivers.", "hasChildren": true @@ -34986,7 +37641,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], + "tags": [ + "automation" + ], "label": "Cron Enabled", "help": "Enables cron job execution for stored schedules managed by the gateway. Keep enabled for normal reminder/automation flows, and disable only to pause all cron execution without deleting jobs.", "hasChildren": false @@ -35046,7 +37703,10 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["announce", "webhook"], + "enumValues": [ + "announce", + "webhook" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -35087,7 +37747,10 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["announce", "webhook"], + "enumValues": [ + "announce", + "webhook" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -35110,7 +37773,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "performance"], + "tags": [ + "automation", + "performance" + ], "label": "Cron Max Concurrent Runs", "help": "Limits how many cron jobs can execute at the same time when multiple schedules fire together. Use lower values to protect CPU/memory under heavy automation load, or raise carefully for higher throughput.", "hasChildren": false @@ -35122,7 +37788,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "reliability"], + "tags": [ + "automation", + "reliability" + ], "label": "Cron Retry Policy", "help": "Overrides the default retry policy for one-shot jobs when they fail with transient errors (rate limit, overloaded, network, server_error). Omit to use defaults: maxAttempts 3, backoffMs [30000, 60000, 300000], retry all transient types.", "hasChildren": true @@ -35134,7 +37803,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "reliability"], + "tags": [ + "automation", + "reliability" + ], "label": "Cron Retry Backoff (ms)", "help": "Backoff delays in ms for each retry attempt (default: [30000, 60000, 300000]). Use shorter values for faster retries.", "hasChildren": true @@ -35156,7 +37828,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "performance", "reliability"], + "tags": [ + "automation", + "performance", + "reliability" + ], "label": "Cron Retry Max Attempts", "help": "Max retries for one-shot jobs on transient errors before permanent disable (default: 3).", "hasChildren": false @@ -35168,7 +37844,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "reliability"], + "tags": [ + "automation", + "reliability" + ], "label": "Cron Retry Error Types", "help": "Error types to retry: rate_limit, overloaded, network, timeout, server_error. Use to restrict which errors trigger retries; omit to retry all transient types.", "hasChildren": true @@ -35178,7 +37857,13 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["rate_limit", "overloaded", "network", "timeout", "server_error"], + "enumValues": [ + "rate_limit", + "overloaded", + "network", + "timeout", + "server_error" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -35191,7 +37876,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], + "tags": [ + "automation" + ], "label": "Cron Run Log Pruning", "help": "Pruning controls for per-job cron run history files under `cron/runs/.jsonl`, including size and line retention.", "hasChildren": true @@ -35203,7 +37890,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], + "tags": [ + "automation" + ], "label": "Cron Run Log Keep Lines", "help": "How many trailing run-log lines to retain when a file exceeds maxBytes (default `2000`). Increase for longer forensic history or lower for smaller disks.", "hasChildren": false @@ -35211,11 +37900,17 @@ { "path": "cron.runLog.maxBytes", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "performance"], + "tags": [ + "automation", + "performance" + ], "label": "Cron Run Log Max Bytes", "help": "Maximum bytes per cron run-log file before pruning rewrites to the last keepLines entries (for example `2mb`, default `2000000`).", "hasChildren": false @@ -35223,11 +37918,17 @@ { "path": "cron.sessionRetention", "kind": "core", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "storage"], + "tags": [ + "automation", + "storage" + ], "label": "Cron Session Retention", "help": "Controls how long completed cron run sessions are kept before pruning (`24h`, `7d`, `1h30m`, or `false` to disable pruning; default: `24h`). Use shorter retention to reduce storage growth on high-frequency schedules.", "hasChildren": false @@ -35239,7 +37940,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "storage"], + "tags": [ + "automation", + "storage" + ], "label": "Cron Store Path", "help": "Path to the cron job store file used to persist scheduled jobs across restarts. Set an explicit path only when you need custom storage layout, backups, or mounted volumes.", "hasChildren": false @@ -35251,7 +37955,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], + "tags": [ + "automation" + ], "label": "Cron Legacy Webhook (Deprecated)", "help": "Deprecated legacy fallback webhook URL used only for old jobs with `notify=true`. Migrate to per-job delivery using `delivery.mode=\"webhook\"` plus `delivery.to`, and avoid relying on this global field.", "hasChildren": false @@ -35259,11 +37965,18 @@ { "path": "cron.webhookToken", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "automation", "security"], + "tags": [ + "auth", + "automation", + "security" + ], "label": "Cron Webhook Bearer Token", "help": "Bearer token attached to cron webhook POST deliveries when webhook mode is used. Prefer secret/env substitution and rotate this token regularly if shared webhook endpoints are internet-reachable.", "hasChildren": true @@ -35305,7 +38018,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "Diagnostics", "help": "Diagnostics controls for targeted tracing, telemetry export, and cache inspection during debugging. Keep baseline diagnostics minimal in production and enable deeper signals only when investigating issues.", "hasChildren": true @@ -35317,7 +38032,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "storage"], + "tags": [ + "observability", + "storage" + ], "label": "Cache Trace", "help": "Cache-trace logging settings for observing cache decisions and payload context in embedded runs. Enable this temporarily for debugging and disable afterward to reduce sensitive log footprint.", "hasChildren": true @@ -35329,7 +38047,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "storage"], + "tags": [ + "observability", + "storage" + ], "label": "Cache Trace Enabled", "help": "Log cache trace snapshots for embedded agent runs (default: false).", "hasChildren": false @@ -35341,7 +38062,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "storage"], + "tags": [ + "observability", + "storage" + ], "label": "Cache Trace File Path", "help": "JSONL output path for cache trace logs (default: $OPENCLAW_STATE_DIR/logs/cache-trace.jsonl).", "hasChildren": false @@ -35353,7 +38077,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "storage"], + "tags": [ + "observability", + "storage" + ], "label": "Cache Trace Include Messages", "help": "Include full message payloads in trace output (default: true).", "hasChildren": false @@ -35365,7 +38092,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "storage"], + "tags": [ + "observability", + "storage" + ], "label": "Cache Trace Include Prompt", "help": "Include prompt text in trace output (default: true).", "hasChildren": false @@ -35377,7 +38107,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "storage"], + "tags": [ + "observability", + "storage" + ], "label": "Cache Trace Include System", "help": "Include system prompt in trace output (default: true).", "hasChildren": false @@ -35389,7 +38122,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "Diagnostics Enabled", "help": "Master toggle for diagnostics instrumentation output in logs and telemetry wiring paths. Keep enabled for normal observability, and disable only in tightly constrained environments.", "hasChildren": false @@ -35401,7 +38136,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "Diagnostics Flags", "help": "Enable targeted diagnostics logs by flag (e.g. [\"telegram.http\"]). Supports wildcards like \"telegram.*\" or \"*\".", "hasChildren": true @@ -35423,7 +38160,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "OpenTelemetry", "help": "OpenTelemetry export settings for traces, metrics, and logs emitted by gateway components. Use this when integrating with centralized observability backends and distributed tracing pipelines.", "hasChildren": true @@ -35435,7 +38174,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "OpenTelemetry Enabled", "help": "Enables OpenTelemetry export pipeline for traces, metrics, and logs based on configured endpoint/protocol settings. Keep disabled unless your collector endpoint and auth are fully configured.", "hasChildren": false @@ -35447,7 +38188,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "OpenTelemetry Endpoint", "help": "Collector endpoint URL used for OpenTelemetry export transport, including scheme and port. Use a reachable, trusted collector endpoint and monitor ingestion errors after rollout.", "hasChildren": false @@ -35459,7 +38202,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "performance"], + "tags": [ + "observability", + "performance" + ], "label": "OpenTelemetry Flush Interval (ms)", "help": "Interval in milliseconds for periodic telemetry flush from buffers to the collector. Increase to reduce export chatter, or lower for faster visibility during active incident response.", "hasChildren": false @@ -35471,7 +38217,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "OpenTelemetry Headers", "help": "Additional HTTP/gRPC metadata headers sent with OpenTelemetry export requests, often used for tenant auth or routing. Keep secrets in env-backed values and avoid unnecessary header sprawl.", "hasChildren": true @@ -35493,7 +38241,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "OpenTelemetry Logs Enabled", "help": "Enable log signal export through OpenTelemetry in addition to local logging sinks. Use this when centralized log correlation is required across services and agents.", "hasChildren": false @@ -35505,7 +38255,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "OpenTelemetry Metrics Enabled", "help": "Enable metrics signal export to the configured OpenTelemetry collector endpoint. Keep enabled for runtime health dashboards, and disable only if metric volume must be minimized.", "hasChildren": false @@ -35517,7 +38269,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "OpenTelemetry Protocol", "help": "OTel transport protocol for telemetry export: \"http/protobuf\" or \"grpc\" depending on collector support. Use the protocol your observability backend expects to avoid dropped telemetry payloads.", "hasChildren": false @@ -35529,7 +38283,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "OpenTelemetry Trace Sample Rate", "help": "Trace sampling rate (0-1) controlling how much trace traffic is exported to observability backends. Lower rates reduce overhead/cost, while higher rates improve debugging fidelity.", "hasChildren": false @@ -35541,7 +38297,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "OpenTelemetry Service Name", "help": "Service name reported in telemetry resource attributes to identify this gateway instance in observability backends. Use stable names so dashboards and alerts remain consistent over deployments.", "hasChildren": false @@ -35553,7 +38311,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "OpenTelemetry Traces Enabled", "help": "Enable trace signal export to the configured OpenTelemetry collector endpoint. Keep enabled when latency/debug tracing is needed, and disable if you only want metrics/logs.", "hasChildren": false @@ -35565,7 +38325,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "storage"], + "tags": [ + "observability", + "storage" + ], "label": "Stuck Session Warning Threshold (ms)", "help": "Age threshold in milliseconds for emitting stuck-session warnings while a session remains in processing state. Increase for long multi-tool turns to reduce false positives; decrease for faster hang detection.", "hasChildren": false @@ -35577,7 +38340,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Discovery", "help": "Service discovery settings for local mDNS advertisement and optional wide-area presence signaling. Keep discovery scoped to expected networks to avoid leaking service metadata.", "hasChildren": true @@ -35589,7 +38354,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "mDNS Discovery", "help": "mDNS discovery configuration group for local network advertisement and discovery behavior tuning. Keep minimal mode for routine LAN discovery unless extra metadata is required.", "hasChildren": true @@ -35599,10 +38366,16 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["off", "minimal", "full"], + "enumValues": [ + "off", + "minimal", + "full" + ], "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "mDNS Discovery Mode", "help": "mDNS broadcast mode (\"minimal\" default, \"full\" includes cliPath/sshPort, \"off\" disables mDNS).", "hasChildren": false @@ -35614,7 +38387,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Wide-area Discovery", "help": "Wide-area discovery configuration group for exposing discovery signals beyond local-link scopes. Enable only in deployments that intentionally aggregate gateway presence across sites.", "hasChildren": true @@ -35626,7 +38401,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Wide-area Discovery Domain", "help": "Optional unicast DNS-SD domain for wide-area discovery, such as openclaw.internal. Use this when you intentionally publish gateway discovery beyond local mDNS scopes.", "hasChildren": false @@ -35638,7 +38415,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Wide-area Discovery Enabled", "help": "Enables wide-area discovery signaling when your environment needs non-local gateway discovery. Keep disabled unless cross-network discovery is operationally required.", "hasChildren": false @@ -35650,7 +38429,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Environment", "help": "Environment import and override settings used to supply runtime variables to the gateway process. Use this section to control shell-env loading and explicit variable injection behavior.", "hasChildren": true @@ -35672,7 +38453,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Shell Environment Import", "help": "Shell environment import controls for loading variables from your login shell during startup. Keep this enabled when you depend on profile-defined secrets or PATH customizations.", "hasChildren": true @@ -35684,7 +38467,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Shell Environment Import Enabled", "help": "Enables loading environment variables from the user shell profile during startup initialization. Keep enabled for developer machines, or disable in locked-down service environments with explicit env management.", "hasChildren": false @@ -35696,7 +38481,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Shell Environment Import Timeout (ms)", "help": "Maximum time in milliseconds allowed for shell environment resolution before fallback behavior applies. Use tighter timeouts for faster startup, or increase when shell initialization is heavy.", "hasChildren": false @@ -35708,7 +38495,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Environment Variable Overrides", "help": "Explicit key/value environment variable overrides merged into runtime process environment for OpenClaw. Use this for deterministic env configuration instead of relying only on shell profile side effects.", "hasChildren": true @@ -35730,7 +38519,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gateway", "help": "Gateway runtime surface for bind mode, auth, control UI, remote transport, and operational safety controls. Keep conservative defaults unless you intentionally expose the gateway beyond trusted local interfaces.", "hasChildren": true @@ -35742,7 +38533,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "network", "reliability"], + "tags": [ + "access", + "network", + "reliability" + ], "label": "Gateway Allow x-real-ip Fallback", "help": "Enables x-real-ip fallback when x-forwarded-for is missing in proxy scenarios. Keep disabled unless your ingress stack requires this compatibility behavior.", "hasChildren": false @@ -35754,7 +38549,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Auth", "help": "Authentication policy for gateway HTTP/WebSocket access including mode, credentials, trusted-proxy behavior, and rate limiting. Keep auth enabled for every non-loopback deployment.", "hasChildren": true @@ -35766,7 +38563,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "network"], + "tags": [ + "access", + "network" + ], "label": "Gateway Auth Allow Tailscale Identity", "help": "Allows trusted Tailscale identity paths to satisfy gateway auth checks when configured. Use this only when your tailnet identity posture is strong and operator workflows depend on it.", "hasChildren": false @@ -35778,7 +38578,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Auth Mode", "help": "Gateway auth mode: \"none\", \"token\", \"password\", or \"trusted-proxy\" depending on your edge architecture. Use token/password for direct exposure, and trusted-proxy only behind hardened identity-aware proxies.", "hasChildren": false @@ -35786,11 +38588,19 @@ { "path": "gateway.auth.password", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["access", "auth", "network", "security"], + "tags": [ + "access", + "auth", + "network", + "security" + ], "label": "Gateway Password", "help": "Required for Tailscale funnel.", "hasChildren": true @@ -35832,7 +38642,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "performance"], + "tags": [ + "network", + "performance" + ], "label": "Gateway Auth Rate Limit", "help": "Login/auth attempt throttling controls to reduce credential brute-force risk at the gateway boundary. Keep enabled in exposed environments and tune thresholds to your traffic baseline.", "hasChildren": true @@ -35880,11 +38693,19 @@ { "path": "gateway.auth.token", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["access", "auth", "network", "security"], + "tags": [ + "access", + "auth", + "network", + "security" + ], "label": "Gateway Token", "help": "Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.", "hasChildren": true @@ -35926,7 +38747,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Trusted Proxy Auth", "help": "Trusted-proxy auth header mapping for upstream identity providers that inject user claims. Use only with known proxy CIDRs and strict header allowlists to prevent spoofed identity headers.", "hasChildren": true @@ -35988,7 +38811,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Bind Mode", "help": "Network bind profile: \"auto\", \"lan\", \"loopback\", \"custom\", or \"tailnet\" to control interface exposure. Keep \"loopback\" or \"auto\" for safest local operation unless external clients must connect.", "hasChildren": false @@ -36000,7 +38825,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "reliability"], + "tags": [ + "network", + "reliability" + ], "label": "Gateway Channel Health Check Interval (min)", "help": "Interval in minutes for automatic channel health probing and status updates. Use lower intervals for faster detection, or higher intervals to reduce periodic probe noise.", "hasChildren": false @@ -36012,7 +38840,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "performance"], + "tags": [ + "network", + "performance" + ], "label": "Gateway Channel Max Restarts Per Hour", "help": "Maximum number of health-monitor-initiated channel restarts allowed within a rolling one-hour window. Once hit, further restarts are skipped until the window expires. Default: 10.", "hasChildren": false @@ -36024,7 +38855,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Channel Stale Event Threshold (min)", "help": "How many minutes a connected channel can go without receiving any event before the health monitor treats it as a stale socket and triggers a restart. Default: 30.", "hasChildren": false @@ -36036,7 +38869,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Control UI", "help": "Control UI hosting settings including enablement, pathing, and browser-origin/auth hardening behavior. Keep UI exposure minimal and pair with strong auth controls before internet-facing deployments.", "hasChildren": true @@ -36048,7 +38883,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "network"], + "tags": [ + "access", + "network" + ], "label": "Control UI Allowed Origins", "help": "Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled.", "hasChildren": true @@ -36070,7 +38908,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "advanced", "network", "security"], + "tags": [ + "access", + "advanced", + "network", + "security" + ], "label": "Insecure Control UI Auth Toggle", "help": "Loosens strict browser auth checks for Control UI when you must run a non-standard setup. Keep this off unless you trust your network and proxy path, because impersonation risk is higher.", "hasChildren": false @@ -36082,7 +38925,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "storage"], + "tags": [ + "network", + "storage" + ], "label": "Control UI Base Path", "help": "Optional URL prefix where the Control UI is served (e.g. /openclaw).", "hasChildren": false @@ -36094,7 +38940,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "advanced", "network", "security"], + "tags": [ + "access", + "advanced", + "network", + "security" + ], "label": "Dangerously Allow Host-Header Origin Fallback", "help": "DANGEROUS toggle that enables Host-header based origin fallback for Control UI/WebChat websocket checks. This mode is supported when your deployment intentionally relies on Host-header origin policy; explicit gateway.controlUi.allowedOrigins remains the recommended hardened default.", "hasChildren": false @@ -36106,7 +38957,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "advanced", "network", "security"], + "tags": [ + "access", + "advanced", + "network", + "security" + ], "label": "Dangerously Disable Control UI Device Auth", "help": "Disables Control UI device identity checks and relies on token/password only. Use only for short-lived debugging on trusted networks, then turn it off immediately.", "hasChildren": false @@ -36118,7 +38974,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Control UI Enabled", "help": "Enables serving the gateway Control UI from the gateway HTTP process when true. Keep enabled for local administration, and disable when an external control surface replaces it.", "hasChildren": false @@ -36130,7 +38988,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Control UI Assets Root", "help": "Optional filesystem root for Control UI assets (defaults to dist/control-ui).", "hasChildren": false @@ -36142,7 +39002,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Custom Bind Host", "help": "Explicit bind host/IP used when gateway.bind is set to custom for manual interface targeting. Use a precise address and avoid wildcard binds unless external exposure is required.", "hasChildren": false @@ -36154,7 +39016,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway HTTP API", "help": "Gateway HTTP API configuration grouping endpoint toggles and transport-facing API exposure controls. Keep only required endpoints enabled to reduce attack surface.", "hasChildren": true @@ -36166,7 +39030,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway HTTP Endpoints", "help": "HTTP endpoint feature toggles under the gateway API surface for compatibility routes and optional integrations. Enable endpoints intentionally and monitor access patterns after rollout.", "hasChildren": true @@ -36188,7 +39054,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "OpenAI Chat Completions Endpoint", "help": "Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).", "hasChildren": false @@ -36200,7 +39068,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "network"], + "tags": [ + "media", + "network" + ], "label": "OpenAI Chat Completions Image Limits", "help": "Image fetch/validation controls for OpenAI-compatible `image_url` parts.", "hasChildren": true @@ -36212,7 +39083,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "media", "network"], + "tags": [ + "access", + "media", + "network" + ], "label": "OpenAI Chat Completions Image MIME Allowlist", "help": "Allowed MIME types for `image_url` parts (case-insensitive list).", "hasChildren": true @@ -36234,7 +39109,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "media", "network"], + "tags": [ + "access", + "media", + "network" + ], "label": "OpenAI Chat Completions Allow Image URLs", "help": "Allow server-side URL fetches for `image_url` parts (default: false; data URIs remain supported).", "hasChildren": false @@ -36246,7 +39125,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "network", "performance"], + "tags": [ + "media", + "network", + "performance" + ], "label": "OpenAI Chat Completions Image Max Bytes", "help": "Max bytes per fetched/decoded `image_url` image (default: 10MB).", "hasChildren": false @@ -36258,7 +39141,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "network", "performance", "storage"], + "tags": [ + "media", + "network", + "performance", + "storage" + ], "label": "OpenAI Chat Completions Image Max Redirects", "help": "Max HTTP redirects allowed when fetching `image_url` URLs (default: 3).", "hasChildren": false @@ -36270,7 +39158,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "network", "performance"], + "tags": [ + "media", + "network", + "performance" + ], "label": "OpenAI Chat Completions Image Timeout (ms)", "help": "Timeout in milliseconds for `image_url` URL fetches (default: 10000).", "hasChildren": false @@ -36282,7 +39174,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "media", "network"], + "tags": [ + "access", + "media", + "network" + ], "label": "OpenAI Chat Completions Image URL Allowlist", "help": "Optional hostname allowlist for `image_url` URL fetches; supports exact hosts and `*.example.com` wildcards.", "hasChildren": true @@ -36304,7 +39200,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "performance"], + "tags": [ + "network", + "performance" + ], "label": "OpenAI Chat Completions Max Body Bytes", "help": "Max request body size in bytes for `/v1/chat/completions` (default: 20MB).", "hasChildren": false @@ -36316,7 +39215,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "network", "performance"], + "tags": [ + "media", + "network", + "performance" + ], "label": "OpenAI Chat Completions Max Image Parts", "help": "Max number of `image_url` parts accepted from the latest user message (default: 8).", "hasChildren": false @@ -36328,7 +39231,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "network", "performance"], + "tags": [ + "media", + "network", + "performance" + ], "label": "OpenAI Chat Completions Max Total Image Bytes", "help": "Max cumulative decoded bytes across all `image_url` parts in one request (default: 20MB).", "hasChildren": false @@ -36610,7 +39517,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway HTTP Security Headers", "help": "Optional HTTP response security headers applied by the gateway process itself. Prefer setting these at your reverse proxy when TLS terminates there.", "hasChildren": true @@ -36618,11 +39527,16 @@ { "path": "gateway.http.securityHeaders.strictTransportSecurity", "kind": "core", - "type": ["boolean", "string"], + "type": [ + "boolean", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Strict Transport Security Header", "help": "Value for the Strict-Transport-Security response header. Set only on HTTPS origins that you fully control; use false to explicitly disable.", "hasChildren": false @@ -36634,7 +39548,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Mode", "help": "Gateway operation mode: \"local\" runs channels and agent runtime on this host, while \"remote\" connects through remote transport. Keep \"local\" unless you intentionally run a split remote gateway topology.", "hasChildren": false @@ -36656,7 +39572,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "network"], + "tags": [ + "access", + "network" + ], "label": "Gateway Node Allowlist (Extra Commands)", "help": "Extra node.invoke commands to allow beyond the gateway defaults (array of command strings). Enabling dangerous commands here is a security-sensitive override and is flagged by `openclaw security audit`.", "hasChildren": true @@ -36688,7 +39607,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Node Browser Mode", "help": "Node browser routing (\"auto\" = pick single connected browser node, \"manual\" = require node param, \"off\" = disable).", "hasChildren": false @@ -36700,7 +39621,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Node Browser Pin", "help": "Pin browser routing to a specific node id or name (optional).", "hasChildren": false @@ -36712,7 +39635,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "network"], + "tags": [ + "access", + "network" + ], "label": "Gateway Node Denylist", "help": "Node command names to block even if present in node claims or default allowlist (exact command-name matching only, e.g. `system.run`; does not inspect shell text inside that command).", "hasChildren": true @@ -36734,7 +39660,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Port", "help": "TCP port used by the gateway listener for API, control UI, and channel-facing ingress paths. Use a dedicated port and avoid collisions with reverse proxies or local developer services.", "hasChildren": false @@ -36746,7 +39674,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Push Delivery", "help": "Push-delivery settings used by the gateway when it needs to wake or notify paired devices. Configure relay-backed APNs here for official iOS builds; direct APNs auth remains env-based for local/manual builds.", "hasChildren": true @@ -36758,7 +39688,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway APNs Delivery", "help": "APNs delivery settings for iOS devices paired to this gateway. Use relay settings for official/TestFlight builds that register through the external push relay.", "hasChildren": true @@ -36770,7 +39702,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway APNs Relay", "help": "External relay settings for relay-backed APNs sends. The gateway uses this relay for push.test, wake nudges, and reconnect wakes after a paired official iOS build publishes a relay-backed registration.", "hasChildren": true @@ -36782,7 +39716,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "network"], + "tags": [ + "advanced", + "network" + ], "label": "Gateway APNs Relay Base URL", "help": "Base HTTPS URL for the external APNs relay service used by official/TestFlight iOS builds. Keep this aligned with the relay URL baked into the iOS build so registration and send traffic hit the same deployment.", "hasChildren": false @@ -36794,7 +39731,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "performance"], + "tags": [ + "network", + "performance" + ], "label": "Gateway APNs Relay Timeout (ms)", "help": "Timeout in milliseconds for relay send requests from the gateway to the APNs relay (default: 10000). Increase for slower relays or networks, or lower to fail wake attempts faster.", "hasChildren": false @@ -36806,7 +39746,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "reliability"], + "tags": [ + "network", + "reliability" + ], "label": "Config Reload", "help": "Live config-reload policy for how edits are applied and when full restarts are triggered. Keep hybrid behavior for safest operational updates unless debugging reload internals.", "hasChildren": true @@ -36818,7 +39761,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "performance", "reliability"], + "tags": [ + "network", + "performance", + "reliability" + ], "label": "Config Reload Debounce (ms)", "help": "Debounce window (ms) before applying config changes.", "hasChildren": false @@ -36830,7 +39777,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "reliability"], + "tags": [ + "network", + "reliability" + ], "label": "Config Reload Mode", "help": "Controls how config edits are applied: \"off\" ignores live edits, \"restart\" always restarts, \"hot\" applies in-process, and \"hybrid\" tries hot then restarts if required. Keep \"hybrid\" for safest routine updates.", "hasChildren": false @@ -36842,7 +39792,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Remote Gateway", "help": "Remote gateway connection settings for direct or SSH transport when this instance proxies to another runtime host. Use remote mode only when split-host operation is intentionally configured.", "hasChildren": true @@ -36850,11 +39802,18 @@ { "path": "gateway.remote.password", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "network", "security"], + "tags": [ + "auth", + "network", + "security" + ], "label": "Remote Gateway Password", "help": "Password credential used for remote gateway authentication when password mode is enabled. Keep this secret managed externally and avoid plaintext values in committed config.", "hasChildren": true @@ -36896,7 +39855,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Remote Gateway SSH Identity", "help": "Optional SSH identity file path (passed to ssh -i).", "hasChildren": false @@ -36908,7 +39869,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Remote Gateway SSH Target", "help": "Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.", "hasChildren": false @@ -36920,7 +39883,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["auth", "network", "security"], + "tags": [ + "auth", + "network", + "security" + ], "label": "Remote Gateway TLS Fingerprint", "help": "Expected sha256 TLS fingerprint for the remote gateway (pin to avoid MITM).", "hasChildren": false @@ -36928,11 +39895,18 @@ { "path": "gateway.remote.token", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "network", "security"], + "tags": [ + "auth", + "network", + "security" + ], "label": "Remote Gateway Token", "help": "Bearer token used to authenticate this client to a remote gateway in token-auth deployments. Store via secret/env substitution and rotate alongside remote gateway auth changes.", "hasChildren": true @@ -36974,7 +39948,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Remote Gateway Transport", "help": "Remote connection transport: \"direct\" uses configured URL connectivity, while \"ssh\" tunnels through SSH. Use SSH when you need encrypted tunnel semantics without exposing remote ports.", "hasChildren": false @@ -36986,7 +39962,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Remote Gateway URL", "help": "Remote Gateway WebSocket URL (ws:// or wss://).", "hasChildren": false @@ -36998,7 +39976,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Tailscale", "help": "Tailscale integration settings for Serve/Funnel exposure and lifecycle handling on gateway start/exit. Keep off unless your deployment intentionally relies on Tailscale ingress.", "hasChildren": true @@ -37010,7 +39990,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Tailscale Mode", "help": "Tailscale publish mode: \"off\", \"serve\", or \"funnel\" for private or public exposure paths. Use \"serve\" for tailnet-only access and \"funnel\" only when public internet reachability is required.", "hasChildren": false @@ -37022,7 +40004,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Tailscale Reset on Exit", "help": "Resets Tailscale Serve/Funnel state on gateway exit to avoid stale published routes after shutdown. Keep enabled unless another controller manages publish lifecycle outside the gateway.", "hasChildren": false @@ -37034,7 +40018,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway TLS", "help": "TLS certificate and key settings for terminating HTTPS directly in the gateway process. Use explicit certificates in production and avoid plaintext exposure on untrusted networks.", "hasChildren": true @@ -37046,7 +40032,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway TLS Auto-Generate Cert", "help": "Auto-generates a local TLS certificate/key pair when explicit files are not configured. Use only for local/dev setups and replace with real certificates for production traffic.", "hasChildren": false @@ -37058,7 +40046,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "storage"], + "tags": [ + "network", + "storage" + ], "label": "Gateway TLS CA Path", "help": "Optional CA bundle path for client verification or custom trust-chain requirements at the gateway edge. Use this when private PKI or custom certificate chains are part of deployment.", "hasChildren": false @@ -37070,7 +40061,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "storage"], + "tags": [ + "network", + "storage" + ], "label": "Gateway TLS Certificate Path", "help": "Filesystem path to the TLS certificate file used by the gateway when TLS is enabled. Use managed certificate paths and keep renewal automation aligned with this location.", "hasChildren": false @@ -37082,7 +40076,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway TLS Enabled", "help": "Enables TLS termination at the gateway listener so clients connect over HTTPS/WSS directly. Keep enabled for direct internet exposure or any untrusted network boundary.", "hasChildren": false @@ -37094,7 +40090,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network", "storage"], + "tags": [ + "network", + "storage" + ], "label": "Gateway TLS Key Path", "help": "Filesystem path to the TLS private key file used by the gateway when TLS is enabled. Keep this key file permission-restricted and rotate per your security policy.", "hasChildren": false @@ -37106,7 +40105,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Tool Exposure Policy", "help": "Gateway-level tool exposure allow/deny policy that can restrict runtime tool availability independent of agent/tool profiles. Use this for coarse emergency controls and production hardening.", "hasChildren": true @@ -37118,7 +40119,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "network"], + "tags": [ + "access", + "network" + ], "label": "Gateway Tool Allowlist", "help": "Explicit gateway-level tool allowlist when you want a narrow set of tools available at runtime. Use this for locked-down environments where tool scope must be tightly controlled.", "hasChildren": true @@ -37140,7 +40144,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "network"], + "tags": [ + "access", + "network" + ], "label": "Gateway Tool Denylist", "help": "Explicit gateway-level tool denylist to block risky tools even if lower-level policies allow them. Use deny rules for emergency response and defense-in-depth hardening.", "hasChildren": true @@ -37162,7 +40169,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Gateway Trusted Proxy CIDRs", "help": "CIDR/IP allowlist of upstream proxies permitted to provide forwarded client identity headers. Keep this list narrow so untrusted hops cannot impersonate users.", "hasChildren": true @@ -37184,7 +40193,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hooks", "help": "Inbound webhook automation surface for mapping external events into wake or agent actions in OpenClaw. Keep this locked down with explicit token/session/agent controls before exposing it beyond trusted networks.", "hasChildren": true @@ -37196,7 +40207,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Hooks Allowed Agent IDs", "help": "Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents.", "hasChildren": true @@ -37218,7 +40231,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Hooks Allowed Session Key Prefixes", "help": "Allowlist of accepted session-key prefixes for inbound hook requests when caller-provided keys are enabled. Use narrow prefixes to prevent arbitrary session-key injection.", "hasChildren": true @@ -37240,7 +40256,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Hooks Allow Request Session Key", "help": "Allows callers to supply a session key in hook requests when true, enabling caller-controlled routing. Keep false unless trusted integrators explicitly need custom session threading.", "hasChildren": false @@ -37252,7 +40271,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Hooks Default Session Key", "help": "Fallback session key used for hook deliveries when a request does not provide one through allowed channels. Use a stable but scoped key to avoid mixing unrelated automation conversations.", "hasChildren": false @@ -37264,7 +40285,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hooks Enabled", "help": "Enables the hooks endpoint and mapping execution pipeline for inbound webhook requests. Keep disabled unless you are actively routing external events into the gateway.", "hasChildren": false @@ -37276,7 +40299,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook", "help": "Gmail push integration settings used for Pub/Sub notifications and optional local callback serving. Keep this scoped to dedicated Gmail automation accounts where possible.", "hasChildren": true @@ -37288,7 +40313,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Account", "help": "Google account identifier used for Gmail watch/subscription operations in this hook integration. Use a dedicated automation mailbox account to isolate operational permissions.", "hasChildren": false @@ -37300,7 +40327,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Gmail Hook Allow Unsafe External Content", "help": "Allows less-sanitized external Gmail content to pass into processing when enabled. Keep disabled for safer defaults, and enable only for trusted mail streams with controlled transforms.", "hasChildren": false @@ -37312,7 +40341,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Callback URL", "help": "Public callback URL Gmail or intermediaries invoke to deliver notifications into this hook pipeline. Keep this URL protected with token validation and restricted network exposure.", "hasChildren": false @@ -37324,7 +40355,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Include Body", "help": "When true, fetch and include email body content for downstream mapping/agent processing. Keep false unless body text is required, because this increases payload size and sensitivity.", "hasChildren": false @@ -37336,7 +40369,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Label", "help": "Optional Gmail label filter limiting which labeled messages trigger hook events. Keep filters narrow to avoid flooding automations with unrelated inbox traffic.", "hasChildren": false @@ -37348,7 +40383,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Gmail Hook Max Body Bytes", "help": "Maximum Gmail payload bytes processed per event when includeBody is enabled. Keep conservative limits to reduce oversized message processing cost and risk.", "hasChildren": false @@ -37360,7 +40397,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Gmail Hook Model Override", "help": "Optional model override for Gmail-triggered runs when mailbox automations should use dedicated model behavior. Keep unset to inherit agent defaults unless mailbox tasks need specialization.", "hasChildren": false @@ -37372,7 +40411,10 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "label": "Gmail Hook Push Token", "help": "Shared secret token required on Gmail push hook callbacks before processing notifications. Use env substitution and rotate if callback endpoints are exposed externally.", "hasChildren": false @@ -37384,7 +40426,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Renew Interval (min)", "help": "Renewal cadence in minutes for Gmail watch subscriptions to prevent expiration. Set below provider expiration windows and monitor renew failures in logs.", "hasChildren": false @@ -37396,7 +40440,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Local Server", "help": "Local callback server settings block for directly receiving Gmail notifications without a separate ingress layer. Enable only when this process should terminate webhook traffic itself.", "hasChildren": true @@ -37408,7 +40454,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Server Bind Address", "help": "Bind address for the local Gmail callback HTTP server used when serving hooks directly. Keep loopback-only unless external ingress is intentionally required.", "hasChildren": false @@ -37420,7 +40468,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Gmail Hook Server Path", "help": "HTTP path on the local Gmail callback server where push notifications are accepted. Keep this consistent with subscription configuration to avoid dropped events.", "hasChildren": false @@ -37432,7 +40482,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Server Port", "help": "Port for the local Gmail callback HTTP server when serve mode is enabled. Use a dedicated port to avoid collisions with gateway/control interfaces.", "hasChildren": false @@ -37444,7 +40496,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Subscription", "help": "Pub/Sub subscription consumed by the gateway to receive Gmail change notifications from the configured topic. Keep subscription ownership clear so multiple consumers do not race unexpectedly.", "hasChildren": false @@ -37456,7 +40510,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Tailscale", "help": "Tailscale exposure configuration block for publishing Gmail callbacks through Serve/Funnel routes. Use private tailnet modes before enabling any public ingress path.", "hasChildren": true @@ -37468,7 +40524,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Tailscale Mode", "help": "Tailscale exposure mode for Gmail callbacks: \"off\", \"serve\", or \"funnel\". Use \"serve\" for private tailnet delivery and \"funnel\" only when public internet ingress is required.", "hasChildren": false @@ -37480,7 +40538,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Gmail Hook Tailscale Path", "help": "Path published by Tailscale Serve/Funnel for Gmail callback forwarding when enabled. Keep it aligned with Gmail webhook config so requests reach the expected handler.", "hasChildren": false @@ -37492,7 +40552,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Tailscale Target", "help": "Local service target forwarded by Tailscale Serve/Funnel (for example http://127.0.0.1:8787). Use explicit loopback targets to avoid ambiguous routing.", "hasChildren": false @@ -37504,7 +40566,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Thinking Override", "help": "Thinking effort override for Gmail-driven agent runs: \"off\", \"minimal\", \"low\", \"medium\", or \"high\". Keep modest defaults for routine inbox automations to control cost and latency.", "hasChildren": false @@ -37516,7 +40580,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gmail Hook Pub/Sub Topic", "help": "Google Pub/Sub topic name used by Gmail watch to publish change notifications for this account. Ensure the topic IAM grants Gmail publish access before enabling watches.", "hasChildren": false @@ -37528,7 +40594,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Internal Hooks", "help": "Internal hook runtime settings for bundled/custom event handlers loaded from module paths. Use this for trusted in-process automations and keep handler loading tightly scoped.", "hasChildren": true @@ -37540,7 +40608,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Internal Hooks Enabled", "help": "Enables processing for internal hook handlers and configured entries in the internal hook runtime. Keep disabled unless internal hook handlers are intentionally configured.", "hasChildren": false @@ -37552,7 +40622,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Internal Hook Entries", "help": "Configured internal hook entry records used to register concrete runtime handlers and metadata. Keep entries explicit and versioned so production behavior is auditable.", "hasChildren": true @@ -37613,7 +40685,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Internal Hook Handlers", "help": "List of internal event handlers mapping event names to modules and optional exports. Keep handler definitions explicit so event-to-code routing is auditable.", "hasChildren": true @@ -37635,7 +40709,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Internal Hook Event", "help": "Internal event name that triggers this handler module when emitted by the runtime. Use stable event naming conventions to avoid accidental overlap across handlers.", "hasChildren": false @@ -37647,7 +40723,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Internal Hook Export", "help": "Optional named export for the internal hook handler function when module default export is not used. Set this when one module ships multiple handler entrypoints.", "hasChildren": false @@ -37659,7 +40737,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Internal Hook Module", "help": "Safe relative module path for the internal hook handler implementation loaded at runtime. Keep module files in reviewed directories and avoid dynamic path composition.", "hasChildren": false @@ -37671,7 +40751,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Internal Hook Install Records", "help": "Install metadata for internal hook modules, including source and resolved artifacts for repeatable deployments. Use this as operational provenance and avoid manual drift edits.", "hasChildren": true @@ -37833,7 +40915,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Internal Hook Loader", "help": "Internal hook loader settings controlling where handler modules are discovered at startup. Use constrained load roots to reduce accidental module conflicts or shadowing.", "hasChildren": true @@ -37845,7 +40929,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Internal Hook Extra Directories", "help": "Additional directories searched for internal hook modules beyond default load paths. Keep this minimal and controlled to reduce accidental module shadowing.", "hasChildren": true @@ -37867,7 +40953,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mappings", "help": "Ordered mapping rules that match inbound hook requests and choose wake or agent actions with optional delivery routing. Use specific mappings first to avoid broad pattern rules capturing everything.", "hasChildren": true @@ -37889,7 +40977,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Action", "help": "Mapping action type: \"wake\" triggers agent wake flow, while \"agent\" sends directly to agent handling. Use \"agent\" for immediate execution and \"wake\" when heartbeat-driven processing is preferred.", "hasChildren": false @@ -37901,7 +40991,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Agent ID", "help": "Target agent ID for mapping execution when action routing should not use defaults. Use dedicated automation agents to isolate webhook behavior from interactive operator sessions.", "hasChildren": false @@ -37913,7 +41005,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Hook Mapping Allow Unsafe External Content", "help": "When true, mapping content may include less-sanitized external payload data in generated messages. Keep false by default and enable only for trusted sources with reviewed transform logic.", "hasChildren": false @@ -37925,7 +41019,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Delivery Channel", "help": "Delivery channel override for mapping outputs (for example \"last\", \"telegram\", \"discord\", \"slack\", \"signal\", \"imessage\", or \"msteams\"). Keep channel overrides explicit to avoid accidental cross-channel sends.", "hasChildren": false @@ -37937,7 +41033,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Deliver Reply", "help": "Controls whether mapping execution results are delivered back to a channel destination versus being processed silently. Disable delivery for background automations that should not post user-facing output.", "hasChildren": false @@ -37949,7 +41047,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping ID", "help": "Optional stable identifier for a hook mapping entry used for auditing, troubleshooting, and targeted updates. Use unique IDs so logs and config diffs can reference mappings unambiguously.", "hasChildren": false @@ -37961,7 +41061,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Match", "help": "Grouping object for mapping match predicates such as path and source before action routing is applied. Keep match criteria specific so unrelated webhook traffic does not trigger automations.", "hasChildren": true @@ -37973,7 +41075,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Hook Mapping Match Path", "help": "Path match condition for a hook mapping, usually compared against the inbound request path. Use this to split automation behavior by webhook endpoint path families.", "hasChildren": false @@ -37985,7 +41089,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Match Source", "help": "Source match condition for a hook mapping, typically set by trusted upstream metadata or adapter logic. Use stable source identifiers so routing remains deterministic across retries.", "hasChildren": false @@ -37997,7 +41103,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Message Template", "help": "Template for synthesizing structured mapping input into the final message content sent to the target action path. Keep templates deterministic so downstream parsing and behavior remain stable.", "hasChildren": false @@ -38009,7 +41117,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Hook Mapping Model Override", "help": "Optional model override for mapping-triggered runs when automation should use a different model than agent defaults. Use this sparingly so behavior remains predictable across mapping executions.", "hasChildren": false @@ -38021,7 +41131,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Name", "help": "Human-readable mapping display name used in diagnostics and operator-facing config UIs. Keep names concise and descriptive so routing intent is obvious during incident review.", "hasChildren": false @@ -38033,7 +41145,10 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["security", "storage"], + "tags": [ + "security", + "storage" + ], "label": "Hook Mapping Session Key", "help": "Explicit session key override for mapping-delivered messages to control thread continuity. Use stable scoped keys so repeated events correlate without leaking into unrelated conversations.", "hasChildren": false @@ -38045,7 +41160,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Text Template", "help": "Text-only fallback template used when rich payload rendering is not desired or not supported. Use this to provide a concise, consistent summary string for chat delivery surfaces.", "hasChildren": false @@ -38057,7 +41174,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Thinking Override", "help": "Optional thinking-effort override for mapping-triggered runs to tune latency versus reasoning depth. Keep low or minimal for high-volume hooks unless deeper reasoning is clearly required.", "hasChildren": false @@ -38069,7 +41188,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Hook Mapping Timeout (sec)", "help": "Maximum runtime allowed for mapping action execution before timeout handling applies. Use tighter limits for high-volume webhook sources to prevent queue pileups.", "hasChildren": false @@ -38081,7 +41202,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Delivery Destination", "help": "Destination identifier inside the selected channel when mapping replies should route to a fixed target. Verify provider-specific destination formats before enabling production mappings.", "hasChildren": false @@ -38093,7 +41216,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Transform", "help": "Transform configuration block defining module/export preprocessing before mapping action handling. Use transforms only from reviewed code paths and keep behavior deterministic for repeatable automation.", "hasChildren": true @@ -38105,7 +41230,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Transform Export", "help": "Named export to invoke from the transform module; defaults to module default export when omitted. Set this when one file hosts multiple transform handlers.", "hasChildren": false @@ -38117,7 +41244,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Transform Module", "help": "Relative transform module path loaded from hooks.transformsDir to rewrite incoming payloads before delivery. Keep modules local, reviewed, and free of path traversal patterns.", "hasChildren": false @@ -38129,7 +41258,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hook Mapping Wake Mode", "help": "Wake scheduling mode: \"now\" wakes immediately, while \"next-heartbeat\" defers until the next heartbeat cycle. Use deferred mode for lower-priority automations that can tolerate slight delay.", "hasChildren": false @@ -38141,7 +41272,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Hooks Max Body Bytes", "help": "Maximum accepted webhook payload size in bytes before the request is rejected. Keep this bounded to reduce abuse risk and protect memory usage under bursty integrations.", "hasChildren": false @@ -38153,7 +41286,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Hooks Endpoint Path", "help": "HTTP path used by the hooks endpoint (for example `/hooks`) on the gateway control server. Use a non-guessable path and combine it with token validation for defense in depth.", "hasChildren": false @@ -38165,7 +41300,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Hooks Presets", "help": "Named hook preset bundles applied at load time to seed standard mappings and behavior defaults. Keep preset usage explicit so operators can audit which automations are active.", "hasChildren": true @@ -38187,7 +41324,10 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "label": "Hooks Auth Token", "help": "Shared bearer token checked by hooks ingress for request authentication before mappings run. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.", "hasChildren": false @@ -38199,7 +41339,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Hooks Transforms Directory", "help": "Base directory for hook transform modules referenced by mapping transform.module paths. Use a controlled repo directory so dynamic imports remain reviewable and predictable.", "hasChildren": false @@ -38211,7 +41353,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Logging", "help": "Logging behavior controls for severity, output destinations, formatting, and sensitive-data redaction. Keep levels and redaction strict enough for production while preserving useful diagnostics.", "hasChildren": true @@ -38223,7 +41367,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "Console Log Level", "help": "Console-specific log threshold: \"silent\", \"fatal\", \"error\", \"warn\", \"info\", \"debug\", or \"trace\" for terminal output control. Use this to keep local console quieter while retaining richer file logging if needed.", "hasChildren": false @@ -38235,7 +41381,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "Console Log Style", "help": "Console output format style: \"pretty\", \"compact\", or \"json\" based on operator and ingestion needs. Use json for machine parsing pipelines and pretty/compact for human-first terminal workflows.", "hasChildren": false @@ -38247,7 +41395,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "storage"], + "tags": [ + "observability", + "storage" + ], "label": "Log File Path", "help": "Optional file path for persisted log output in addition to or instead of console logging. Use a managed writable path and align retention/rotation with your operational policy.", "hasChildren": false @@ -38259,7 +41410,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "Log Level", "help": "Primary log level threshold for runtime logger output: \"silent\", \"fatal\", \"error\", \"warn\", \"info\", \"debug\", or \"trace\". Keep \"info\" or \"warn\" for production, and use debug/trace only during investigation.", "hasChildren": false @@ -38281,7 +41434,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "privacy"], + "tags": [ + "observability", + "privacy" + ], "label": "Custom Redaction Patterns", "help": "Additional custom redact regex patterns applied to log output before emission/storage. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.", "hasChildren": true @@ -38303,7 +41459,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability", "privacy"], + "tags": [ + "observability", + "privacy" + ], "label": "Sensitive Data Redaction Mode", "help": "Sensitive redaction mode: \"off\" disables built-in masking, while \"tools\" redacts sensitive tool/config payload fields. Keep \"tools\" in shared logs unless you have isolated secure log sinks.", "hasChildren": false @@ -38315,7 +41474,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Media", "help": "Top-level media behavior shared across providers and tools that handle inbound files. Keep defaults unless you need stable filenames for external processing pipelines or longer-lived inbound media retention.", "hasChildren": true @@ -38327,7 +41488,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Preserve Media Filenames", "help": "When enabled, uploaded media keeps its original filename instead of a generated temp-safe name. Turn this on when downstream automations depend on stable names, and leave off to reduce accidental filename leakage.", "hasChildren": false @@ -38339,7 +41502,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Media Retention TTL (hours)", "help": "Optional retention window in hours for persisted inbound media cleanup across the full media tree. Leave unset to preserve legacy behavior, or set values like 24 (1 day) or 168 (7 days) when you want automatic cleanup.", "hasChildren": false @@ -38351,7 +41516,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory", "help": "Memory backend configuration (global).", "hasChildren": true @@ -38363,7 +41530,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Memory Backend", "help": "Selects the global memory engine: \"builtin\" uses OpenClaw memory internals, while \"qmd\" uses the QMD sidecar pipeline. Keep \"builtin\" unless you intentionally operate QMD.", "hasChildren": false @@ -38375,7 +41544,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Memory Citations Mode", "help": "Controls citation visibility in replies: \"auto\" shows citations when useful, \"on\" always shows them, and \"off\" hides them. Keep \"auto\" for a balanced signal-to-noise default.", "hasChildren": false @@ -38397,7 +41568,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD Binary", "help": "Sets the executable path for the `qmd` binary used by the QMD backend (default: resolved from PATH). Use an explicit absolute path when multiple qmd installs exist or PATH differs across environments.", "hasChildren": false @@ -38409,7 +41582,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD Include Default Memory", "help": "Automatically indexes default memory files (MEMORY.md and memory/**/*.md) into QMD collections. Keep enabled unless you want indexing controlled only through explicit custom paths.", "hasChildren": false @@ -38431,7 +41606,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "QMD Max Injected Chars", "help": "Caps how much QMD text can be injected into one turn across all hits. Use lower values to control prompt bloat and latency; raise only when context is consistently truncated.", "hasChildren": false @@ -38443,7 +41621,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "QMD Max Results", "help": "Limits how many QMD hits are returned into the agent loop for each recall request (default: 6). Increase for broader recall context, or lower to keep prompts tighter and faster.", "hasChildren": false @@ -38455,7 +41636,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "QMD Max Snippet Chars", "help": "Caps per-result snippet length extracted from QMD hits in characters (default: 700). Lower this when prompts bloat quickly, and raise only if answers consistently miss key details.", "hasChildren": false @@ -38467,7 +41651,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "QMD Search Timeout (ms)", "help": "Sets per-query QMD search timeout in milliseconds (default: 4000). Increase for larger indexes or slower environments, and lower to keep request latency bounded.", "hasChildren": false @@ -38479,7 +41666,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD MCPorter", "help": "Routes QMD work through mcporter (MCP runtime) instead of spawning `qmd` for each call. Use this when cold starts are expensive on large models; keep direct process mode for simpler local setups.", "hasChildren": true @@ -38491,7 +41680,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD MCPorter Enabled", "help": "Routes QMD through an mcporter daemon instead of spawning qmd per request, reducing cold-start overhead for larger models. Keep disabled unless mcporter is installed and configured.", "hasChildren": false @@ -38503,7 +41694,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD MCPorter Server Name", "help": "Names the mcporter server target used for QMD calls (default: qmd). Change only when your mcporter setup uses a custom server name for qmd mcp keep-alive.", "hasChildren": false @@ -38515,7 +41708,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD MCPorter Start Daemon", "help": "Automatically starts the mcporter daemon when mcporter-backed QMD mode is enabled (default: true). Keep enabled unless process lifecycle is managed externally by your service supervisor.", "hasChildren": false @@ -38527,7 +41722,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD Extra Paths", "help": "Adds custom directories or files to include in QMD indexing, each with an optional name and glob pattern. Use this for project-specific knowledge locations that are outside default memory paths.", "hasChildren": true @@ -38579,7 +41776,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD Surface Scope", "help": "Defines which sessions/channels are eligible for QMD recall using session.sendPolicy-style rules. Keep default direct-only scope unless you intentionally want cross-chat memory sharing.", "hasChildren": true @@ -38681,7 +41880,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD Search Mode", "help": "Selects the QMD retrieval path: \"query\" uses standard query flow, \"search\" uses search-oriented retrieval, and \"vsearch\" emphasizes vector retrieval. Keep default unless tuning relevance quality.", "hasChildren": false @@ -38703,7 +41904,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD Session Indexing", "help": "Indexes session transcripts into QMD so recall can include prior conversation content (experimental, default: false). Enable only when transcript memory is required and you accept larger index churn.", "hasChildren": false @@ -38715,7 +41918,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD Session Export Directory", "help": "Overrides where sanitized session exports are written before QMD indexing. Use this when default state storage is constrained or when exports must land on a managed volume.", "hasChildren": false @@ -38727,7 +41932,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD Session Retention (days)", "help": "Defines how long exported session files are kept before automatic pruning, in days (default: unlimited). Set a finite value for storage hygiene or compliance retention policies.", "hasChildren": false @@ -38749,7 +41956,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "QMD Command Timeout (ms)", "help": "Sets timeout for QMD maintenance commands such as collection list/add in milliseconds (default: 30000). Increase when running on slower disks or remote filesystems that delay command completion.", "hasChildren": false @@ -38761,7 +41971,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "QMD Update Debounce (ms)", "help": "Sets the minimum delay between consecutive QMD refresh attempts in milliseconds (default: 15000). Increase this if frequent file changes cause update thrash or unnecessary background load.", "hasChildren": false @@ -38773,7 +41986,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "QMD Embed Interval", "help": "Sets how often QMD recomputes embeddings (duration string, default: 60m; set 0 to disable periodic embeds). Lower intervals improve freshness but increase embedding workload and cost.", "hasChildren": false @@ -38785,7 +42001,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "QMD Embed Timeout (ms)", "help": "Sets maximum runtime for each `qmd embed` cycle in milliseconds (default: 120000). Increase for heavier embedding workloads or slower hardware, and lower to fail fast under tight SLAs.", "hasChildren": false @@ -38797,7 +42016,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "QMD Update Interval", "help": "Sets how often QMD refreshes indexes from source content (duration string, default: 5m). Shorter intervals improve freshness but increase background CPU and I/O.", "hasChildren": false @@ -38809,7 +42031,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD Update on Startup", "help": "Runs an initial QMD update once during gateway startup (default: true). Keep enabled so recall starts from a fresh baseline; disable only when startup speed is more important than immediate freshness.", "hasChildren": false @@ -38821,7 +42045,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "QMD Update Timeout (ms)", "help": "Sets maximum runtime for each `qmd update` cycle in milliseconds (default: 120000). Raise this for larger collections; lower it when you want quicker failure detection in automation.", "hasChildren": false @@ -38833,7 +42060,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "QMD Wait for Boot Sync", "help": "Blocks startup completion until the initial boot-time QMD sync finishes (default: false). Enable when you need fully up-to-date recall before serving traffic, and keep off for faster boot.", "hasChildren": false @@ -38845,7 +42074,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Messages", "help": "Message formatting, acknowledgment, queueing, debounce, and status reaction behavior for inbound/outbound chat flows. Use this section when channel responsiveness or message UX needs adjustment.", "hasChildren": true @@ -38857,7 +42088,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Ack Reaction Emoji", "help": "Emoji reaction used to acknowledge inbound messages (empty disables).", "hasChildren": false @@ -38867,10 +42100,19 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["group-mentions", "group-all", "direct", "all", "off", "none"], + "enumValues": [ + "group-mentions", + "group-all", + "direct", + "all", + "off", + "none" + ], "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Ack Reaction Scope", "help": "When to send ack reactions (\"group-mentions\", \"group-all\", \"direct\", \"all\", \"off\", \"none\"). \"off\"/\"none\" disables ack reactions entirely.", "hasChildren": false @@ -38882,7 +42124,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Group Chat Rules", "help": "Group-message handling controls including mention triggers and history window sizing. Keep mention patterns narrow so group channels do not trigger on every message.", "hasChildren": true @@ -38894,7 +42138,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Group History Limit", "help": "Maximum number of prior group messages loaded as context per turn for group sessions. Use higher values for richer continuity, or lower values for faster and cheaper responses.", "hasChildren": false @@ -38906,7 +42152,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Group Mention Patterns", "help": "Safe case-insensitive regex patterns used to detect explicit mentions/trigger phrases in group chats. Use precise patterns to reduce false positives in high-volume channels; invalid or unsafe nested-repetition patterns are ignored.", "hasChildren": true @@ -38928,7 +42176,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Inbound Debounce", "help": "Direct inbound debounce settings used before queue/turn processing starts. Configure this for provider-specific rapid message bursts from the same sender.", "hasChildren": true @@ -38940,7 +42190,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Inbound Debounce by Channel (ms)", "help": "Per-channel inbound debounce overrides keyed by provider id in milliseconds. Use this where some providers send message fragments more aggressively than others.", "hasChildren": true @@ -38962,7 +42214,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Inbound Message Debounce (ms)", "help": "Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).", "hasChildren": false @@ -38974,7 +42228,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Inbound Message Prefix", "help": "Prefix text prepended to inbound user messages before they are handed to the agent runtime. Use this sparingly for channel context markers and keep it stable across sessions.", "hasChildren": false @@ -38986,7 +42242,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Inbound Queue", "help": "Inbound message queue strategy used to buffer bursts before processing turns. Tune this for busy channels where sequential processing or batching behavior matters.", "hasChildren": true @@ -38998,7 +42256,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Queue Mode by Channel", "help": "Per-channel queue mode overrides keyed by provider id (for example telegram, discord, slack). Use this when one channel’s traffic pattern needs different queue behavior than global defaults.", "hasChildren": true @@ -39110,7 +42370,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Queue Capacity", "help": "Maximum number of queued inbound items retained before drop policy applies. Keep caps bounded in noisy channels so memory usage remains predictable.", "hasChildren": false @@ -39122,7 +42384,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Queue Debounce (ms)", "help": "Global queue debounce window in milliseconds before processing buffered inbound messages. Use higher values to coalesce rapid bursts, or lower values for reduced response latency.", "hasChildren": false @@ -39134,7 +42398,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Queue Debounce by Channel (ms)", "help": "Per-channel debounce overrides for queue behavior keyed by provider id. Use this to tune burst handling independently for chat surfaces with different pacing.", "hasChildren": true @@ -39156,7 +42422,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Queue Drop Strategy", "help": "Drop strategy when queue cap is exceeded: \"old\", \"new\", or \"summarize\". Use summarize when preserving intent matters, or old/new when deterministic dropping is preferred.", "hasChildren": false @@ -39168,7 +42436,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Queue Mode", "help": "Queue behavior mode: \"steer\", \"followup\", \"collect\", \"steer-backlog\", \"steer+backlog\", \"queue\", or \"interrupt\". Keep conservative modes unless you intentionally need aggressive interruption/backlog semantics.", "hasChildren": false @@ -39180,7 +42450,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Remove Ack Reaction After Reply", "help": "Removes the acknowledgment reaction after final reply delivery when enabled. Keep enabled for cleaner UX in channels where persistent ack reactions create clutter.", "hasChildren": false @@ -39192,7 +42464,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Outbound Response Prefix", "help": "Prefix text prepended to outbound assistant replies before sending to channels. Use for lightweight branding/context tags and avoid long prefixes that reduce content density.", "hasChildren": false @@ -39204,7 +42478,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Status Reactions", "help": "Lifecycle status reactions that update the emoji on the trigger message as the agent progresses (queued → thinking → tool → done/error).", "hasChildren": true @@ -39216,7 +42492,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Status Reaction Emojis", "help": "Override default status reaction emojis. Keys: thinking, compacting, tool, coding, web, done, error, stallSoft, stallHard. Must be valid Telegram reaction emojis.", "hasChildren": true @@ -39318,7 +42596,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable Status Reactions", "help": "Enable lifecycle status reactions for Telegram. When enabled, the ack reaction becomes the initial 'queued' state and progresses through thinking, tool, done/error automatically. Default: false.", "hasChildren": false @@ -39330,7 +42610,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Status Reaction Timing", "help": "Override default timing. Keys: debounceMs (700), stallSoftMs (25000), stallHardMs (60000), doneHoldMs (1500), errorHoldMs (2500).", "hasChildren": true @@ -39392,7 +42674,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Suppress Tool Error Warnings", "help": "When true, suppress ⚠️ tool-error warnings from being shown to the user. The agent already sees errors in context and can retry. Default: false.", "hasChildren": false @@ -39404,7 +42688,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Message Text-to-Speech", "help": "Text-to-speech policy for reading agent replies aloud on supported voice or audio surfaces. Keep disabled unless voice playback is part of your operator/user workflow.", "hasChildren": true @@ -39414,7 +42700,12 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["off", "always", "inbound", "tagged"], + "enumValues": [ + "off", + "always", + "inbound", + "tagged" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -39543,11 +42834,18 @@ { "path": "messages.tts.elevenlabs.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "media", "security"], + "tags": [ + "auth", + "media", + "security" + ], "hasChildren": true }, { @@ -39585,7 +42883,11 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["auto", "on", "off"], + "enumValues": [ + "auto", + "on", + "off" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -39726,7 +43028,10 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["final", "all"], + "enumValues": [ + "final", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -39835,11 +43140,18 @@ { "path": "messages.tts.openai.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "media", "security"], + "tags": [ + "auth", + "media", + "security" + ], "hasChildren": true }, { @@ -39937,7 +43249,11 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["elevenlabs", "openai", "edge"], + "enumValues": [ + "elevenlabs", + "openai", + "edge" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -39970,7 +43286,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Metadata", "help": "Metadata fields automatically maintained by OpenClaw to record write/version history for this config file. Keep these values system-managed and avoid manual edits unless debugging migration history.", "hasChildren": true @@ -39982,7 +43300,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Config Last Touched At", "help": "ISO timestamp of the last config write (auto-set).", "hasChildren": false @@ -39994,7 +43314,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Config Last Touched Version", "help": "Auto-set when OpenClaw writes the config.", "hasChildren": false @@ -40006,7 +43328,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Models", "help": "Model catalog root for provider definitions, merge/replace behavior, and optional Bedrock discovery integration. Keep provider definitions explicit and validated before relying on production failover paths.", "hasChildren": true @@ -40018,7 +43342,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Bedrock Model Discovery", "help": "Automatic AWS Bedrock model discovery settings used to synthesize provider model entries from account visibility. Keep discovery scoped and refresh intervals conservative to reduce API churn.", "hasChildren": true @@ -40030,7 +43356,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Bedrock Default Context Window", "help": "Fallback context-window value applied to discovered models when provider metadata lacks explicit limits. Use realistic defaults to avoid oversized prompts that exceed true provider constraints.", "hasChildren": false @@ -40042,7 +43370,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["auth", "models", "performance", "security"], + "tags": [ + "auth", + "models", + "performance", + "security" + ], "label": "Bedrock Default Max Tokens", "help": "Fallback max-token value applied to discovered models without explicit output token limits. Use conservative defaults to reduce truncation surprises and unexpected token spend.", "hasChildren": false @@ -40054,7 +43387,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Bedrock Discovery Enabled", "help": "Enables periodic Bedrock model discovery and catalog refresh for Bedrock-backed providers. Keep disabled unless Bedrock is actively used and IAM permissions are correctly configured.", "hasChildren": false @@ -40066,7 +43401,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Bedrock Discovery Provider Filter", "help": "Optional provider allowlist filter for Bedrock discovery so only selected providers are refreshed. Use this to limit discovery scope in multi-provider environments.", "hasChildren": true @@ -40088,7 +43425,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models", "performance"], + "tags": [ + "models", + "performance" + ], "label": "Bedrock Discovery Refresh Interval (s)", "help": "Refresh cadence for Bedrock discovery polling in seconds to detect newly available models over time. Use longer intervals in production to reduce API cost and control-plane noise.", "hasChildren": false @@ -40100,7 +43440,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Bedrock Discovery Region", "help": "AWS region used for Bedrock discovery calls when discovery is enabled for your deployment. Use the region where your Bedrock models are provisioned to avoid empty discovery results.", "hasChildren": false @@ -40112,7 +43454,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Model Catalog Mode", "help": "Controls provider catalog behavior: \"merge\" keeps built-ins and overlays your custom providers, while \"replace\" uses only your configured providers. In \"merge\", matching provider IDs preserve non-empty agent models.json baseUrl values, while apiKey values are preserved only when the provider is not SecretRef-managed in current config/auth-profile context; SecretRef-managed providers refresh apiKey from current source markers, and matching model contextWindow/maxTokens use the higher value between explicit and implicit entries.", "hasChildren": false @@ -40124,7 +43468,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Model Providers", "help": "Provider map keyed by provider ID containing connection/auth settings and concrete model definitions. Use stable provider keys so references from agents and tooling remain portable across environments.", "hasChildren": true @@ -40156,7 +43502,9 @@ ], "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Model Provider API Adapter", "help": "Provider API adapter selection controlling request/response compatibility handling for model calls. Use the adapter that matches your upstream provider protocol to avoid feature mismatch.", "hasChildren": false @@ -40164,11 +43512,18 @@ { "path": "models.providers.*.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "models", "security"], + "tags": [ + "auth", + "models", + "security" + ], "label": "Model Provider API Key", "help": "Provider credential used for API-key based authentication when the provider requires direct key auth. Use secret/env substitution and avoid storing real keys in committed config files.", "hasChildren": true @@ -40210,7 +43565,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Model Provider Auth Mode", "help": "Selects provider auth style: \"api-key\" for API key auth, \"token\" for bearer token auth, \"oauth\" for OAuth credentials, and \"aws-sdk\" for AWS credential resolution. Match this to your provider requirements.", "hasChildren": false @@ -40222,7 +43579,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Model Provider Authorization Header", "help": "When true, credentials are sent via the HTTP Authorization header even if alternate auth is possible. Use this only when your provider or proxy explicitly requires Authorization forwarding.", "hasChildren": false @@ -40234,7 +43593,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Model Provider Base URL", "help": "Base URL for the provider endpoint used to serve model requests for that provider entry. Use HTTPS endpoints and keep URLs environment-specific through config templating where needed.", "hasChildren": false @@ -40246,7 +43607,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Model Provider Headers", "help": "Static HTTP headers merged into provider requests for tenant routing, proxy auth, or custom gateway requirements. Use this sparingly and keep sensitive header values in secrets.", "hasChildren": true @@ -40254,11 +43617,17 @@ { "path": "models.providers.*.headers.*", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["models", "security"], + "tags": [ + "models", + "security" + ], "hasChildren": true }, { @@ -40298,7 +43667,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Model Provider Inject num_ctx (OpenAI Compat)", "help": "Controls whether OpenClaw injects `options.num_ctx` for Ollama providers configured with the OpenAI-compatible adapter (`openai-completions`). Default is true. Set false only if your proxy/upstream rejects unknown `options` payload fields.", "hasChildren": false @@ -40310,7 +43681,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["models"], + "tags": [ + "models" + ], "label": "Model Provider Model List", "help": "Declared model list for a provider including identifiers, metadata, and optional compatibility/cost hints. Keep IDs exact to provider catalog values so selection and fallback resolve correctly.", "hasChildren": true @@ -40632,7 +44005,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Node Host", "help": "Node host controls for features exposed from this gateway node to other nodes or clients. Keep defaults unless you intentionally proxy local capabilities across your node network.", "hasChildren": true @@ -40644,7 +44019,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Node Browser Proxy", "help": "Groups browser-proxy settings for exposing local browser control through node routing. Enable only when remote node workflows need your local browser profiles.", "hasChildren": true @@ -40656,7 +44033,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "network", "storage"], + "tags": [ + "access", + "network", + "storage" + ], "label": "Node Browser Proxy Allowed Profiles", "help": "Optional allowlist of browser profile names exposed through node proxy routing. Leave empty to expose all configured profiles, or use a tight list to enforce least-privilege profile access.", "hasChildren": true @@ -40678,7 +44059,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["network"], + "tags": [ + "network" + ], "label": "Node Browser Proxy Enabled", "help": "Expose the local browser control server through node proxy routing so remote clients can use this host's browser capabilities. Keep disabled unless remote automation explicitly depends on it.", "hasChildren": false @@ -40690,7 +44073,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugins", "help": "Plugin system controls for enabling extensions, constraining load scope, configuring entries, and tracking installs. Keep plugin policy explicit and least-privilege in production environments.", "hasChildren": true @@ -40702,7 +44087,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Plugin Allowlist", "help": "Optional allowlist of plugin IDs; when set, only listed plugins are eligible to load. Use this to enforce approved extension inventories in controlled environments.", "hasChildren": true @@ -40724,7 +44111,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Plugin Denylist", "help": "Optional denylist of plugin IDs that are blocked even if allowlists or paths include them. Use deny rules for emergency rollback and hard blocks on risky plugins.", "hasChildren": true @@ -40746,7 +44135,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable Plugins", "help": "Enable or disable plugin/extension loading globally during startup and config reload (default: true). Keep enabled only when extension capabilities are required by your deployment.", "hasChildren": false @@ -40758,7 +44149,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Entries", "help": "Per-plugin settings keyed by plugin ID including enablement and plugin-specific runtime configuration payloads. Use this for scoped plugin tuning without changing global loader policy.", "hasChildren": true @@ -40780,7 +44173,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Config", "help": "Plugin-defined configuration payload interpreted by that plugin's own schema and validation rules. Use only documented fields from the plugin to prevent ignored or invalid settings.", "hasChildren": true @@ -40801,7 +44196,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Enabled", "help": "Per-plugin enablement override for a specific entry, applied on top of global plugin policy (restart required). Use this to stage plugin rollout gradually across environments.", "hasChildren": false @@ -40813,7 +44210,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -40825,7 +44224,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -40837,7 +44238,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACPX Runtime", "help": "ACP runtime backend powered by acpx with configurable command path and version policy. (plugin: acpx)", "hasChildren": true @@ -40849,7 +44252,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ACPX Runtime Config", "help": "Plugin-defined config payload for acpx.", "hasChildren": true @@ -40861,7 +44266,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "acpx Command", "help": "Optional path/command override for acpx (for example /home/user/repos/acpx/dist/cli.js). Leave unset to use plugin-local bundled acpx.", "hasChildren": false @@ -40873,7 +44280,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default Working Directory", "help": "Default cwd for ACP session operations when not set per session.", "hasChildren": false @@ -40885,7 +44294,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Expected acpx Version", "help": "Exact version to enforce (for example 0.1.16) or \"any\" to skip strict version matching.", "hasChildren": false @@ -40897,7 +44308,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "MCP Servers", "help": "Named MCP server definitions to inject into ACPX-backed session bootstrap. Each entry needs a command and can include args and env.", "hasChildren": true @@ -40967,10 +44380,15 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["deny", "fail"], + "enumValues": [ + "deny", + "fail" + ], "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Non-Interactive Permission Policy", "help": "acpx policy when interactive permission prompts are unavailable.", "hasChildren": false @@ -40980,10 +44398,16 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["approve-all", "approve-reads", "deny-all"], + "enumValues": [ + "approve-all", + "approve-reads", + "deny-all" + ], "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Permission Mode", "help": "Default acpx permission policy for runtime prompts.", "hasChildren": false @@ -40995,7 +44419,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "advanced"], + "tags": [ + "access", + "advanced" + ], "label": "Queue Owner TTL Seconds", "help": "Idle queue-owner TTL for acpx prompt turns. Keep this short in OpenClaw to avoid delayed completion after each turn.", "hasChildren": false @@ -41007,7 +44434,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Strict Windows cmd Wrapper", "help": "Enabled by default. On Windows, reject unresolved .cmd/.bat wrappers instead of shell fallback. Disable only for compatibility with non-standard wrappers.", "hasChildren": false @@ -41019,7 +44448,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "performance"], + "tags": [ + "advanced", + "performance" + ], "label": "Prompt Timeout Seconds", "help": "Optional acpx timeout for each runtime turn.", "hasChildren": false @@ -41031,7 +44463,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable ACPX Runtime", "hasChildren": false }, @@ -41042,7 +44476,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41054,7 +44490,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41066,7 +44504,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/bluebubbles", "help": "OpenClaw BlueBubbles channel plugin (plugin: bluebubbles)", "hasChildren": true @@ -41078,7 +44518,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/bluebubbles Config", "help": "Plugin-defined config payload for bluebubbles.", "hasChildren": false @@ -41090,7 +44532,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/bluebubbles", "hasChildren": false }, @@ -41101,7 +44545,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41113,7 +44559,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41125,7 +44573,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/copilot-proxy", "help": "OpenClaw Copilot Proxy provider plugin (plugin: copilot-proxy)", "hasChildren": true @@ -41137,7 +44587,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/copilot-proxy Config", "help": "Plugin-defined config payload for copilot-proxy.", "hasChildren": false @@ -41149,7 +44601,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/copilot-proxy", "hasChildren": false }, @@ -41160,7 +44614,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41172,7 +44628,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41184,7 +44642,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Device Pairing", "help": "Generate setup codes and approve device pairing requests. (plugin: device-pair)", "hasChildren": true @@ -41196,7 +44656,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Device Pairing Config", "help": "Plugin-defined config payload for device-pair.", "hasChildren": true @@ -41208,7 +44670,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Gateway URL", "help": "Public WebSocket URL used for /pair setup codes (ws/wss or http/https).", "hasChildren": false @@ -41220,7 +44684,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable Device Pairing", "hasChildren": false }, @@ -41231,7 +44697,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41243,7 +44711,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41255,7 +44725,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "@openclaw/diagnostics-otel", "help": "OpenClaw diagnostics OpenTelemetry exporter (plugin: diagnostics-otel)", "hasChildren": true @@ -41267,7 +44739,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "@openclaw/diagnostics-otel Config", "help": "Plugin-defined config payload for diagnostics-otel.", "hasChildren": false @@ -41279,7 +44753,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["observability"], + "tags": [ + "observability" + ], "label": "Enable @openclaw/diagnostics-otel", "hasChildren": false }, @@ -41290,7 +44766,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41302,7 +44780,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41314,7 +44794,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Diffs", "help": "Read-only diff viewer and file renderer for agents. (plugin: diffs)", "hasChildren": true @@ -41326,7 +44808,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Diffs Config", "help": "Plugin-defined config payload for diffs.", "hasChildren": true @@ -41349,7 +44833,9 @@ "defaultValue": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default Background Highlights", "help": "Show added/removed background highlights by default.", "hasChildren": false @@ -41359,11 +44845,17 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["bars", "classic", "none"], + "enumValues": [ + "bars", + "classic", + "none" + ], "defaultValue": "bars", "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Diff Indicator Style", "help": "Choose added/removed indicators style.", "hasChildren": false @@ -41373,11 +44865,16 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["png", "pdf"], + "enumValues": [ + "png", + "pdf" + ], "defaultValue": "png", "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Default File Format", "help": "Rendered file format for file mode (PNG or PDF).", "hasChildren": false @@ -41390,7 +44887,10 @@ "defaultValue": 960, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "Default File Max Width", "help": "Maximum file render width in CSS pixels.", "hasChildren": false @@ -41400,11 +44900,17 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["standard", "hq", "print"], + "enumValues": [ + "standard", + "hq", + "print" + ], "defaultValue": "standard", "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Default File Quality", "help": "Quality preset for PNG/PDF rendering.", "hasChildren": false @@ -41417,7 +44923,9 @@ "defaultValue": 2, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Default File Scale", "help": "Device scale factor used while rendering file artifacts.", "hasChildren": false @@ -41430,7 +44938,9 @@ "defaultValue": "Fira Code", "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default Font", "help": "Preferred font family name for diff content and headers.", "hasChildren": false @@ -41443,7 +44953,9 @@ "defaultValue": 15, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default Font Size", "help": "Base diff font size in pixels.", "hasChildren": false @@ -41453,7 +44965,10 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["png", "pdf"], + "enumValues": [ + "png", + "pdf" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -41464,7 +44979,10 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["png", "pdf"], + "enumValues": [ + "png", + "pdf" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -41485,7 +45003,11 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["standard", "hq", "print"], + "enumValues": [ + "standard", + "hq", + "print" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -41506,11 +45028,16 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["unified", "split"], + "enumValues": [ + "unified", + "split" + ], "defaultValue": "unified", "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default Layout", "help": "Initial diff layout shown in the viewer.", "hasChildren": false @@ -41523,7 +45050,9 @@ "defaultValue": 1.6, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default Line Spacing", "help": "Line-height multiplier applied to diff rows.", "hasChildren": false @@ -41533,11 +45062,18 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["view", "image", "file", "both"], + "enumValues": [ + "view", + "image", + "file", + "both" + ], "defaultValue": "both", "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default Output Mode", "help": "Tool default when mode is omitted. Use view for canvas/gateway viewer, file for PNG/PDF, or both.", "hasChildren": false @@ -41550,7 +45086,9 @@ "defaultValue": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Show Line Numbers", "help": "Show line numbers by default.", "hasChildren": false @@ -41560,11 +45098,16 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["light", "dark"], + "enumValues": [ + "light", + "dark" + ], "defaultValue": "dark", "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default Theme", "help": "Initial viewer theme.", "hasChildren": false @@ -41577,7 +45120,9 @@ "defaultValue": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default Word Wrap", "help": "Wrap long lines by default.", "hasChildren": false @@ -41600,7 +45145,9 @@ "defaultValue": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Remote Viewer", "help": "Allow non-loopback access to diff viewer URLs when the token path is known.", "hasChildren": false @@ -41612,7 +45159,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable Diffs", "hasChildren": false }, @@ -41623,7 +45172,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41635,7 +45186,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41647,7 +45200,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/discord", "help": "OpenClaw Discord channel plugin (plugin: discord)", "hasChildren": true @@ -41659,7 +45214,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/discord Config", "help": "Plugin-defined config payload for discord.", "hasChildren": false @@ -41671,7 +45228,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/discord", "hasChildren": false }, @@ -41682,7 +45241,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41694,7 +45255,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41706,7 +45269,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/feishu", "help": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng) (plugin: feishu)", "hasChildren": true @@ -41718,7 +45283,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/feishu Config", "help": "Plugin-defined config payload for feishu.", "hasChildren": false @@ -41730,7 +45297,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/feishu", "hasChildren": false }, @@ -41741,7 +45310,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41753,7 +45324,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41765,7 +45338,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/google-gemini-cli-auth", "help": "OpenClaw Gemini CLI OAuth provider plugin (plugin: google-gemini-cli-auth)", "hasChildren": true @@ -41777,7 +45352,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/google-gemini-cli-auth Config", "help": "Plugin-defined config payload for google-gemini-cli-auth.", "hasChildren": false @@ -41789,7 +45366,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/google-gemini-cli-auth", "hasChildren": false }, @@ -41800,7 +45379,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41812,7 +45393,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41824,7 +45407,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/googlechat", "help": "OpenClaw Google Chat channel plugin (plugin: googlechat)", "hasChildren": true @@ -41836,7 +45421,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/googlechat Config", "help": "Plugin-defined config payload for googlechat.", "hasChildren": false @@ -41848,7 +45435,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/googlechat", "hasChildren": false }, @@ -41859,7 +45448,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41871,7 +45462,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41883,7 +45476,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/imessage", "help": "OpenClaw iMessage channel plugin (plugin: imessage)", "hasChildren": true @@ -41895,7 +45490,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/imessage Config", "help": "Plugin-defined config payload for imessage.", "hasChildren": false @@ -41907,7 +45504,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/imessage", "hasChildren": false }, @@ -41918,7 +45517,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41930,7 +45531,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -41942,7 +45545,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/irc", "help": "OpenClaw IRC channel plugin (plugin: irc)", "hasChildren": true @@ -41954,7 +45559,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/irc Config", "help": "Plugin-defined config payload for irc.", "hasChildren": false @@ -41966,7 +45573,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/irc", "hasChildren": false }, @@ -41977,7 +45586,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -41989,7 +45600,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -42001,7 +45614,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/line", "help": "OpenClaw LINE channel plugin (plugin: line)", "hasChildren": true @@ -42013,7 +45628,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/line Config", "help": "Plugin-defined config payload for line.", "hasChildren": false @@ -42025,7 +45642,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/line", "hasChildren": false }, @@ -42036,7 +45655,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -42048,7 +45669,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -42060,7 +45683,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "LLM Task", "help": "Generic JSON-only LLM tool for structured tasks callable from workflows. (plugin: llm-task)", "hasChildren": true @@ -42072,7 +45697,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "LLM Task Config", "help": "Plugin-defined config payload for llm-task.", "hasChildren": true @@ -42154,7 +45781,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable LLM Task", "hasChildren": false }, @@ -42165,7 +45794,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -42177,7 +45808,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -42189,7 +45822,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Lobster", "help": "Typed workflow tool with resumable approvals. (plugin: lobster)", "hasChildren": true @@ -42201,7 +45836,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Lobster Config", "help": "Plugin-defined config payload for lobster.", "hasChildren": false @@ -42213,7 +45850,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable Lobster", "hasChildren": false }, @@ -42224,7 +45863,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -42236,7 +45877,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -42248,7 +45891,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/matrix", "help": "OpenClaw Matrix channel plugin (plugin: matrix)", "hasChildren": true @@ -42260,7 +45905,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/matrix Config", "help": "Plugin-defined config payload for matrix.", "hasChildren": false @@ -42272,7 +45919,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/matrix", "hasChildren": false }, @@ -42283,7 +45932,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -42295,7 +45946,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -42307,7 +45960,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/mattermost", "help": "OpenClaw Mattermost channel plugin (plugin: mattermost)", "hasChildren": true @@ -42319,7 +45974,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/mattermost Config", "help": "Plugin-defined config payload for mattermost.", "hasChildren": false @@ -42331,7 +45988,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/mattermost", "hasChildren": false }, @@ -42342,7 +46001,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -42354,7 +46015,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -42366,7 +46029,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/memory-core", "help": "OpenClaw core memory search plugin (plugin: memory-core)", "hasChildren": true @@ -42378,7 +46043,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/memory-core Config", "help": "Plugin-defined config payload for memory-core.", "hasChildren": false @@ -42390,7 +46057,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/memory-core", "hasChildren": false }, @@ -42401,7 +46070,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -42413,7 +46084,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -42425,7 +46098,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "@openclaw/memory-lancedb", "help": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture (plugin: memory-lancedb)", "hasChildren": true @@ -42437,7 +46112,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "@openclaw/memory-lancedb Config", "help": "Plugin-defined config payload for memory-lancedb.", "hasChildren": true @@ -42449,7 +46126,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Auto-Capture", "help": "Automatically capture important information from conversations", "hasChildren": false @@ -42461,7 +46140,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Auto-Recall", "help": "Automatically inject relevant memories into context", "hasChildren": false @@ -42473,7 +46154,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "performance", "storage"], + "tags": [ + "advanced", + "performance", + "storage" + ], "label": "Capture Max Chars", "help": "Maximum message length eligible for auto-capture", "hasChildren": false @@ -42485,7 +46170,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "storage"], + "tags": [ + "advanced", + "storage" + ], "label": "Database Path", "hasChildren": false }, @@ -42506,7 +46194,11 @@ "required": true, "deprecated": false, "sensitive": true, - "tags": ["auth", "security", "storage"], + "tags": [ + "auth", + "security", + "storage" + ], "label": "OpenAI API Key", "help": "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})", "hasChildren": false @@ -42518,7 +46210,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "storage"], + "tags": [ + "advanced", + "storage" + ], "label": "Base URL", "help": "Base URL for compatible providers (e.g. http://localhost:11434/v1)", "hasChildren": false @@ -42530,7 +46225,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "storage"], + "tags": [ + "advanced", + "storage" + ], "label": "Dimensions", "help": "Vector dimensions for custom models (required for non-standard models)", "hasChildren": false @@ -42542,7 +46240,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models", "storage"], + "tags": [ + "models", + "storage" + ], "label": "Embedding Model", "help": "OpenAI embedding model to use", "hasChildren": false @@ -42554,7 +46255,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Enable @openclaw/memory-lancedb", "hasChildren": false }, @@ -42565,7 +46268,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -42577,7 +46282,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -42589,7 +46296,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "@openclaw/minimax-portal-auth", "help": "OpenClaw MiniMax Portal OAuth provider plugin (plugin: minimax-portal-auth)", "hasChildren": true @@ -42601,7 +46310,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "@openclaw/minimax-portal-auth Config", "help": "Plugin-defined config payload for minimax-portal-auth.", "hasChildren": false @@ -42613,7 +46324,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Enable @openclaw/minimax-portal-auth", "hasChildren": false }, @@ -42624,7 +46337,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -42636,7 +46351,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -42648,7 +46365,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/msteams", "help": "OpenClaw Microsoft Teams channel plugin (plugin: msteams)", "hasChildren": true @@ -42660,7 +46379,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/msteams Config", "help": "Plugin-defined config payload for msteams.", "hasChildren": false @@ -42672,7 +46393,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/msteams", "hasChildren": false }, @@ -42683,7 +46406,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -42695,7 +46420,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -42707,7 +46434,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/nextcloud-talk", "help": "OpenClaw Nextcloud Talk channel plugin (plugin: nextcloud-talk)", "hasChildren": true @@ -42719,7 +46448,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/nextcloud-talk Config", "help": "Plugin-defined config payload for nextcloud-talk.", "hasChildren": false @@ -42731,7 +46462,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/nextcloud-talk", "hasChildren": false }, @@ -42742,7 +46475,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -42754,7 +46489,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -42766,7 +46503,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/nostr", "help": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs (plugin: nostr)", "hasChildren": true @@ -42778,7 +46517,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/nostr Config", "help": "Plugin-defined config payload for nostr.", "hasChildren": false @@ -42790,7 +46531,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/nostr", "hasChildren": false }, @@ -42801,7 +46544,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -42813,7 +46558,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -42825,7 +46572,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/ollama-provider", "help": "OpenClaw Ollama provider plugin (plugin: ollama)", "hasChildren": true @@ -42837,7 +46586,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/ollama-provider Config", "help": "Plugin-defined config payload for ollama.", "hasChildren": false @@ -42849,7 +46600,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/ollama-provider", "hasChildren": false }, @@ -42860,7 +46613,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -42872,7 +46627,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -42884,7 +46641,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "OpenProse", "help": "OpenProse VM skill pack with a /prose slash command. (plugin: open-prose)", "hasChildren": true @@ -42896,7 +46655,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "OpenProse Config", "help": "Plugin-defined config payload for open-prose.", "hasChildren": false @@ -42908,7 +46669,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable OpenProse", "hasChildren": false }, @@ -42919,7 +46682,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -42931,7 +46696,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -42943,7 +46710,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Phone Control", "help": "Arm/disarm high-risk phone node commands (camera/screen/writes) with an optional auto-expiry. (plugin: phone-control)", "hasChildren": true @@ -42955,7 +46724,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Phone Control Config", "help": "Plugin-defined config payload for phone-control.", "hasChildren": false @@ -42967,7 +46738,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable Phone Control", "hasChildren": false }, @@ -42978,7 +46751,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -42990,7 +46765,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -43002,7 +46779,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "qwen-portal-auth", "help": "Plugin entry for qwen-portal-auth.", "hasChildren": true @@ -43014,7 +46793,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "qwen-portal-auth Config", "help": "Plugin-defined config payload for qwen-portal-auth.", "hasChildren": false @@ -43026,7 +46807,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable qwen-portal-auth", "hasChildren": false }, @@ -43037,7 +46820,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -43049,7 +46834,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -43061,7 +46848,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/sglang-provider", "help": "OpenClaw SGLang provider plugin (plugin: sglang)", "hasChildren": true @@ -43073,7 +46862,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/sglang-provider Config", "help": "Plugin-defined config payload for sglang.", "hasChildren": false @@ -43085,7 +46876,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/sglang-provider", "hasChildren": false }, @@ -43096,7 +46889,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -43108,7 +46903,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -43120,7 +46917,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/signal", "help": "OpenClaw Signal channel plugin (plugin: signal)", "hasChildren": true @@ -43132,7 +46931,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/signal Config", "help": "Plugin-defined config payload for signal.", "hasChildren": false @@ -43144,7 +46945,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/signal", "hasChildren": false }, @@ -43155,7 +46958,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -43167,7 +46972,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -43179,7 +46986,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/slack", "help": "OpenClaw Slack channel plugin (plugin: slack)", "hasChildren": true @@ -43191,7 +47000,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/slack Config", "help": "Plugin-defined config payload for slack.", "hasChildren": false @@ -43203,7 +47014,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/slack", "hasChildren": false }, @@ -43214,7 +47027,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -43226,7 +47041,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -43238,7 +47055,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/synology-chat", "help": "Synology Chat channel plugin for OpenClaw (plugin: synology-chat)", "hasChildren": true @@ -43250,7 +47069,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/synology-chat Config", "help": "Plugin-defined config payload for synology-chat.", "hasChildren": false @@ -43262,7 +47083,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/synology-chat", "hasChildren": false }, @@ -43273,7 +47096,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -43285,7 +47110,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -43297,7 +47124,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Talk Voice", "help": "Manage Talk voice selection (list/set). (plugin: talk-voice)", "hasChildren": true @@ -43309,7 +47138,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Talk Voice Config", "help": "Plugin-defined config payload for talk-voice.", "hasChildren": false @@ -43321,7 +47152,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable Talk Voice", "hasChildren": false }, @@ -43332,7 +47165,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -43344,7 +47179,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -43356,7 +47193,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/telegram", "help": "OpenClaw Telegram channel plugin (plugin: telegram)", "hasChildren": true @@ -43368,7 +47207,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/telegram Config", "help": "Plugin-defined config payload for telegram.", "hasChildren": false @@ -43380,7 +47221,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/telegram", "hasChildren": false }, @@ -43391,7 +47234,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -43403,7 +47248,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -43415,7 +47262,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Thread Ownership", "help": "Prevents multiple agents from responding in the same Slack thread. Uses HTTP calls to the slack-forwarder ownership API. (plugin: thread-ownership)", "hasChildren": true @@ -43427,7 +47276,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Thread Ownership Config", "help": "Plugin-defined config payload for thread-ownership.", "hasChildren": true @@ -43439,7 +47290,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "A/B Test Channels", "help": "Slack channel IDs where thread ownership is enforced", "hasChildren": true @@ -43461,7 +47314,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Forwarder URL", "help": "Base URL of the slack-forwarder ownership API (default: http://slack-forwarder:8750)", "hasChildren": false @@ -43473,7 +47328,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Enable Thread Ownership", "hasChildren": false }, @@ -43484,7 +47341,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -43496,7 +47355,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -43508,7 +47369,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/tlon", "help": "OpenClaw Tlon/Urbit channel plugin (plugin: tlon)", "hasChildren": true @@ -43520,7 +47383,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/tlon Config", "help": "Plugin-defined config payload for tlon.", "hasChildren": false @@ -43532,7 +47397,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/tlon", "hasChildren": false }, @@ -43543,7 +47410,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -43555,7 +47424,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -43567,7 +47438,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/twitch", "help": "OpenClaw Twitch channel plugin (plugin: twitch)", "hasChildren": true @@ -43579,7 +47452,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/twitch Config", "help": "Plugin-defined config payload for twitch.", "hasChildren": false @@ -43591,7 +47466,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/twitch", "hasChildren": false }, @@ -43602,7 +47479,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -43614,7 +47493,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -43626,7 +47507,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/vllm-provider", "help": "OpenClaw vLLM provider plugin (plugin: vllm)", "hasChildren": true @@ -43638,7 +47521,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/vllm-provider Config", "help": "Plugin-defined config payload for vllm.", "hasChildren": false @@ -43650,7 +47535,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/vllm-provider", "hasChildren": false }, @@ -43661,7 +47548,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -43673,7 +47562,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -43685,7 +47576,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/voice-call", "help": "OpenClaw voice-call plugin (plugin: voice-call)", "hasChildren": true @@ -43697,7 +47590,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/voice-call Config", "help": "Plugin-defined config payload for voice-call.", "hasChildren": true @@ -43709,7 +47604,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Inbound Allowlist", "hasChildren": true }, @@ -43740,7 +47637,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "From Number", "hasChildren": false }, @@ -43751,7 +47650,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Inbound Greeting", "hasChildren": false }, @@ -43760,10 +47661,17 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["disabled", "allowlist", "pairing", "open"], + "enumValues": [ + "disabled", + "allowlist", + "pairing", + "open" + ], "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Inbound Policy", "hasChildren": false }, @@ -43802,10 +47710,15 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["notify", "conversation"], + "enumValues": [ + "notify", + "conversation" + ], "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default Call Mode", "hasChildren": false }, @@ -43816,7 +47729,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Notify Hangup Delay (sec)", "hasChildren": false }, @@ -43855,10 +47770,17 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["telnyx", "twilio", "plivo", "mock"], + "enumValues": [ + "telnyx", + "twilio", + "plivo", + "mock" + ], "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Provider", "help": "Use twilio, telnyx, or mock for dev/no-network.", "hasChildren": false @@ -43870,7 +47792,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Public Webhook URL", "hasChildren": false }, @@ -43881,7 +47805,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Response Model", "hasChildren": false }, @@ -43892,7 +47818,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Response System Prompt", "hasChildren": false }, @@ -43903,7 +47831,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "performance"], + "tags": [ + "advanced", + "performance" + ], "label": "Response Timeout (ms)", "hasChildren": false }, @@ -43934,7 +47865,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Webhook Bind", "hasChildren": false }, @@ -43945,7 +47878,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Webhook Path", "hasChildren": false }, @@ -43956,7 +47891,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Webhook Port", "hasChildren": false }, @@ -43977,7 +47914,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Skip Signature Verification", "hasChildren": false }, @@ -43998,7 +47937,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "storage"], + "tags": [ + "advanced", + "storage" + ], "label": "Call Log Store Path", "hasChildren": false }, @@ -44019,7 +47961,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable Streaming", "hasChildren": false }, @@ -44060,7 +48004,11 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["advanced", "auth", "security"], + "tags": [ + "advanced", + "auth", + "security" + ], "label": "OpenAI Realtime API Key", "hasChildren": false }, @@ -44091,7 +48039,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "storage"], + "tags": [ + "advanced", + "storage" + ], "label": "Media Stream Path", "hasChildren": false }, @@ -44102,7 +48053,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "media"], + "tags": [ + "advanced", + "media" + ], "label": "Realtime STT Model", "hasChildren": false }, @@ -44111,7 +48065,9 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["openai-realtime"], + "enumValues": [ + "openai-realtime" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -44152,7 +48108,9 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["openai"], + "enumValues": [ + "openai" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -44173,10 +48131,16 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["off", "serve", "funnel"], + "enumValues": [ + "off", + "serve", + "funnel" + ], "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Tailscale Mode", "hasChildren": false }, @@ -44187,7 +48151,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "storage"], + "tags": [ + "advanced", + "storage" + ], "label": "Tailscale Path", "hasChildren": false }, @@ -44208,7 +48175,10 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "label": "Telnyx API Key", "hasChildren": false }, @@ -44219,7 +48189,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Telnyx Connection ID", "hasChildren": false }, @@ -44230,7 +48202,9 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["security"], + "tags": [ + "security" + ], "label": "Telnyx Public Key", "hasChildren": false }, @@ -44241,7 +48215,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Default To Number", "hasChildren": false }, @@ -44270,7 +48246,12 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["off", "always", "inbound", "tagged"], + "enumValues": [ + "off", + "always", + "inbound", + "tagged" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -44403,7 +48384,12 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["advanced", "auth", "media", "security"], + "tags": [ + "advanced", + "auth", + "media", + "security" + ], "label": "ElevenLabs API Key", "hasChildren": false }, @@ -44412,7 +48398,11 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["auto", "on", "off"], + "enumValues": [ + "auto", + "on", + "off" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -44425,7 +48415,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "media"], + "tags": [ + "advanced", + "media" + ], "label": "ElevenLabs Base URL", "hasChildren": false }, @@ -44446,7 +48439,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "media", "models"], + "tags": [ + "advanced", + "media", + "models" + ], "label": "ElevenLabs Model ID", "hasChildren": false }, @@ -44467,7 +48464,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "media"], + "tags": [ + "advanced", + "media" + ], "label": "ElevenLabs Voice ID", "hasChildren": false }, @@ -44556,7 +48556,10 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["final", "all"], + "enumValues": [ + "final", + "all" + ], "deprecated": false, "sensitive": false, "tags": [], @@ -44669,7 +48672,12 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["advanced", "auth", "media", "security"], + "tags": [ + "advanced", + "auth", + "media", + "security" + ], "label": "OpenAI API Key", "hasChildren": false }, @@ -44700,7 +48708,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "media", "models"], + "tags": [ + "advanced", + "media", + "models" + ], "label": "OpenAI TTS Model", "hasChildren": false }, @@ -44721,7 +48733,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced", "media"], + "tags": [ + "advanced", + "media" + ], "label": "OpenAI TTS Voice", "hasChildren": false }, @@ -44740,10 +48755,17 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["openai", "elevenlabs", "edge"], + "enumValues": [ + "openai", + "elevenlabs", + "edge" + ], "deprecated": false, "sensitive": false, - "tags": ["advanced", "media"], + "tags": [ + "advanced", + "media" + ], "label": "TTS Provider Override", "help": "Deep-merges with messages.tts (Edge is ignored for calls).", "hasChildren": false @@ -44785,7 +48807,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "advanced"], + "tags": [ + "access", + "advanced" + ], "label": "Allow ngrok Free Tier (Loopback Bypass)", "hasChildren": false }, @@ -44796,7 +48821,11 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["advanced", "auth", "security"], + "tags": [ + "advanced", + "auth", + "security" + ], "label": "ngrok Auth Token", "hasChildren": false }, @@ -44807,7 +48836,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "ngrok Domain", "hasChildren": false }, @@ -44816,10 +48847,17 @@ "kind": "plugin", "type": "string", "required": false, - "enumValues": ["none", "ngrok", "tailscale-serve", "tailscale-funnel"], + "enumValues": [ + "none", + "ngrok", + "tailscale-serve", + "tailscale-funnel" + ], "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Tunnel Provider", "hasChildren": false }, @@ -44840,7 +48878,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Twilio Account SID", "hasChildren": false }, @@ -44851,7 +48891,10 @@ "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "label": "Twilio Auth Token", "hasChildren": false }, @@ -44922,7 +48965,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/voice-call", "hasChildren": false }, @@ -44933,7 +48978,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -44945,7 +48992,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -44957,7 +49006,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/whatsapp", "help": "OpenClaw WhatsApp channel plugin (plugin: whatsapp)", "hasChildren": true @@ -44969,7 +49020,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/whatsapp Config", "help": "Plugin-defined config payload for whatsapp.", "hasChildren": false @@ -44981,7 +49034,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/whatsapp", "hasChildren": false }, @@ -44992,7 +49047,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -45004,7 +49061,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -45016,7 +49075,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/zalo", "help": "OpenClaw Zalo channel plugin (plugin: zalo)", "hasChildren": true @@ -45028,7 +49089,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/zalo Config", "help": "Plugin-defined config payload for zalo.", "hasChildren": false @@ -45040,7 +49103,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/zalo", "hasChildren": false }, @@ -45051,7 +49116,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -45063,7 +49130,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -45075,7 +49144,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/zalouser", "help": "OpenClaw Zalo Personal Account plugin via native zca-js integration (plugin: zalouser)", "hasChildren": true @@ -45087,7 +49158,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "@openclaw/zalouser Config", "help": "Plugin-defined config payload for zalouser.", "hasChildren": false @@ -45099,7 +49172,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Enable @openclaw/zalouser", "hasChildren": false }, @@ -45110,7 +49185,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Hook Policy", "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "hasChildren": true @@ -45122,7 +49199,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access"], + "tags": [ + "access" + ], "label": "Allow Prompt Injection Hooks", "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false @@ -45134,7 +49213,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Install Records", "help": "CLI-managed install metadata (used by `openclaw plugins update` to locate install sources).", "hasChildren": true @@ -45156,7 +49237,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Install Time", "help": "ISO timestamp of last install/update.", "hasChildren": false @@ -45168,7 +49251,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Plugin Install Path", "help": "Resolved install directory (usually ~/.openclaw/extensions/).", "hasChildren": false @@ -45180,7 +49265,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Resolved Integrity", "help": "Resolved npm dist integrity hash for the fetched artifact (if reported by npm).", "hasChildren": false @@ -45192,7 +49279,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Resolution Time", "help": "ISO timestamp when npm package metadata was last resolved for this install record.", "hasChildren": false @@ -45204,7 +49293,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Resolved Package Name", "help": "Resolved npm package name from the fetched artifact.", "hasChildren": false @@ -45216,7 +49307,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Resolved Package Spec", "help": "Resolved exact npm spec (@) from the fetched artifact.", "hasChildren": false @@ -45228,7 +49321,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Resolved Package Version", "help": "Resolved npm package version from the fetched artifact (useful for non-pinned specs).", "hasChildren": false @@ -45240,7 +49335,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Resolved Shasum", "help": "Resolved npm dist shasum for the fetched artifact (if reported by npm).", "hasChildren": false @@ -45252,7 +49349,9 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Install Source", "help": "Install source (\"npm\", \"archive\", or \"path\").", "hasChildren": false @@ -45264,7 +49363,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Plugin Install Source Path", "help": "Original archive/path used for install (if any).", "hasChildren": false @@ -45276,7 +49377,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Install Spec", "help": "Original npm spec used for install (if source is npm).", "hasChildren": false @@ -45288,7 +49391,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Install Version", "help": "Version recorded at install time (if available).", "hasChildren": false @@ -45300,7 +49405,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Loader", "help": "Plugin loader configuration group for specifying filesystem paths where plugins are discovered. Keep load paths explicit and reviewed to avoid accidental untrusted extension loading.", "hasChildren": true @@ -45312,7 +49419,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Plugin Load Paths", "help": "Additional plugin files or directories scanned by the loader beyond built-in defaults. Use dedicated extension directories and avoid broad paths with unrelated executable content.", "hasChildren": true @@ -45334,7 +49443,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Plugin Slots", "help": "Selects which plugins own exclusive runtime slots such as memory so only one plugin provides that capability. Use explicit slot ownership to avoid overlapping providers with conflicting behavior.", "hasChildren": true @@ -45346,7 +49457,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Context Engine Plugin", "help": "Selects the active context engine plugin by id so one plugin provides context orchestration behavior.", "hasChildren": false @@ -45358,7 +49471,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Memory Plugin", "help": "Select the active memory plugin by id, or \"none\" to disable memory plugins.", "hasChildren": false @@ -45690,7 +49805,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session", "help": "Global session routing, reset, delivery policy, and maintenance controls for conversation history behavior. Keep defaults unless you need stricter isolation, retention, or delivery constraints.", "hasChildren": true @@ -45702,7 +49819,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Agent-to-Agent", "help": "Groups controls for inter-agent session exchanges, including loop prevention limits on reply chaining. Keep defaults unless you run advanced agent-to-agent automation with strict turn caps.", "hasChildren": true @@ -45714,7 +49833,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "Agent-to-Agent Ping-Pong Turns", "help": "Max reply-back turns between requester and target agents during agent-to-agent exchanges (0-5). Use lower values to hard-limit chatter loops and preserve predictable run completion.", "hasChildren": false @@ -45726,7 +49848,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "DM Session Scope", "help": "DM session scoping: \"main\" keeps continuity, while \"per-peer\", \"per-channel-peer\", and \"per-account-channel-peer\" increase isolation. Use isolated modes for shared inboxes or multi-account deployments.", "hasChildren": false @@ -45738,7 +49862,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Identity Links", "help": "Maps canonical identities to provider-prefixed peer IDs so equivalent users resolve to one DM thread (example: telegram:123456). Use this when the same human appears across multiple channels or accounts.", "hasChildren": true @@ -45770,7 +49896,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Idle Minutes", "help": "Applies a legacy idle reset window in minutes for session reuse behavior across inactivity gaps. Use this only for compatibility and prefer structured reset policies under session.reset/session.resetByType.", "hasChildren": false @@ -45782,7 +49910,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Main Key", "help": "Overrides the canonical main session key used for continuity when dmScope or routing logic points to \"main\". Use a stable value only if you intentionally need custom session anchoring.", "hasChildren": false @@ -45794,7 +49924,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Maintenance", "help": "Automatic session-store maintenance controls for pruning age, entry caps, and file rotation behavior. Start in warn mode to observe impact, then enforce once thresholds are tuned.", "hasChildren": true @@ -45802,11 +49934,16 @@ { "path": "session.maintenance.highWaterBytes", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Disk High-water Target", "help": "Target size after disk-budget cleanup (high-water mark). Defaults to 80% of maxDiskBytes; set explicitly for tighter reclaim behavior on constrained disks.", "hasChildren": false @@ -45814,11 +49951,17 @@ { "path": "session.maintenance.maxDiskBytes", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "Session Max Disk Budget", "help": "Optional per-agent sessions-directory disk budget (for example `500mb`). Use this to cap session storage per agent; when exceeded, warn mode reports pressure and enforce mode performs oldest-first cleanup.", "hasChildren": false @@ -45830,7 +49973,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "Session Max Entries", "help": "Caps total session entry count retained in the store to prevent unbounded growth over time. Use lower limits for constrained environments, or higher limits when longer history is required.", "hasChildren": false @@ -45840,10 +49986,15 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["enforce", "warn"], + "enumValues": [ + "enforce", + "warn" + ], "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Maintenance Mode", "help": "Determines whether maintenance policies are only reported (\"warn\") or actively applied (\"enforce\"). Keep \"warn\" during rollout and switch to \"enforce\" after validating safe thresholds.", "hasChildren": false @@ -45851,11 +50002,16 @@ { "path": "session.maintenance.pruneAfter", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Prune After", "help": "Removes entries older than this duration (for example `30d` or `12h`) during maintenance passes. Use this as the primary age-retention control and align it with data retention policy.", "hasChildren": false @@ -45867,7 +50023,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Prune Days (Deprecated)", "help": "Deprecated age-retention field kept for compatibility with legacy configs using day counts. Use session.maintenance.pruneAfter instead so duration syntax and behavior are consistent.", "hasChildren": false @@ -45875,11 +50033,17 @@ { "path": "session.maintenance.resetArchiveRetention", "kind": "core", - "type": ["boolean", "number", "string"], + "type": [ + "boolean", + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset Archive Retention", "help": "Retention for reset transcript archives (`*.reset.`). Accepts a duration (for example `30d`), or `false` to disable cleanup. Defaults to pruneAfter so reset artifacts do not grow forever.", "hasChildren": false @@ -45887,11 +50051,16 @@ { "path": "session.maintenance.rotateBytes", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Rotate Size", "help": "Rotates the session store when file size exceeds a threshold such as `10mb` or `1gb`. Use this to bound single-file growth and keep backup/restore operations manageable.", "hasChildren": false @@ -45903,7 +50072,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["auth", "performance", "security", "storage"], + "tags": [ + "auth", + "performance", + "security", + "storage" + ], "label": "Session Parent Fork Max Tokens", "help": "Maximum parent-session token count allowed for thread/session inheritance forking. If the parent exceeds this, OpenClaw starts a fresh thread session instead of forking; set 0 to disable this protection.", "hasChildren": false @@ -45915,7 +50089,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset Policy", "help": "Defines the default reset policy object used when no type-specific or channel-specific override applies. Set this first, then layer resetByType or resetByChannel only where behavior must differ.", "hasChildren": true @@ -45927,7 +50103,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Daily Reset Hour", "help": "Sets local-hour boundary (0-23) for daily reset mode so sessions roll over at predictable times. Use with mode=daily and align to operator timezone expectations for human-readable behavior.", "hasChildren": false @@ -45939,7 +50117,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset Idle Minutes", "help": "Sets inactivity window before reset for idle mode and can also act as secondary guard with daily mode. Use larger values to preserve continuity or smaller values for fresher short-lived threads.", "hasChildren": false @@ -45951,7 +50131,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset Mode", "help": "Selects reset strategy: \"daily\" resets at a configured hour and \"idle\" resets after inactivity windows. Keep one clear mode per policy to avoid surprising context turnover patterns.", "hasChildren": false @@ -45963,7 +50145,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset by Channel", "help": "Provides channel-specific reset overrides keyed by provider/channel id for fine-grained behavior control. Use this only when one channel needs exceptional reset behavior beyond type-level policies.", "hasChildren": true @@ -46015,7 +50199,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset by Chat Type", "help": "Overrides reset behavior by chat type (direct, group, thread) when defaults are not sufficient. Use this when group/thread traffic needs different reset cadence than direct messages.", "hasChildren": true @@ -46027,7 +50213,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset (Direct)", "help": "Defines reset policy for direct chats and supersedes the base session.reset configuration for that type. Use this as the canonical direct-message override instead of the legacy dm alias.", "hasChildren": true @@ -46069,7 +50257,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset (DM Deprecated Alias)", "help": "Deprecated alias for direct reset behavior kept for backward compatibility with older configs. Use session.resetByType.direct instead so future tooling and validation remain consistent.", "hasChildren": true @@ -46111,7 +50301,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset (Group)", "help": "Defines reset policy for group chat sessions where continuity and noise patterns differ from DMs. Use shorter idle windows for busy groups if context drift becomes a problem.", "hasChildren": true @@ -46153,7 +50345,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset (Thread)", "help": "Defines reset policy for thread-scoped sessions, including focused channel thread workflows. Use this when thread sessions should expire faster or slower than other chat types.", "hasChildren": true @@ -46195,7 +50389,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Reset Triggers", "help": "Lists message triggers that force a session reset when matched in inbound content. Use sparingly for explicit reset phrases so context is not dropped unexpectedly during normal conversation.", "hasChildren": true @@ -46217,7 +50413,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Scope", "help": "Sets base session grouping strategy: \"per-sender\" isolates by sender and \"global\" shares one session per channel context. Keep \"per-sender\" for safer multi-user behavior unless deliberate shared context is required.", "hasChildren": false @@ -46229,7 +50427,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Session Send Policy", "help": "Controls cross-session send permissions using allow/deny rules evaluated against channel, chatType, and key prefixes. Use this to fence where session tools can deliver messages in complex environments.", "hasChildren": true @@ -46241,7 +50442,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Session Send Policy Default Action", "help": "Sets fallback action when no sendPolicy rule matches: \"allow\" or \"deny\". Keep \"allow\" for simpler setups, or choose \"deny\" when you require explicit allow rules for every destination.", "hasChildren": false @@ -46253,7 +50457,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Session Send Policy Rules", "help": "Ordered allow/deny rules evaluated before the default action, for example `{ action: \"deny\", match: { channel: \"discord\" } }`. Put most specific rules first so broad rules do not shadow exceptions.", "hasChildren": true @@ -46275,7 +50482,10 @@ "required": true, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Session Send Rule Action", "help": "Defines rule decision as \"allow\" or \"deny\" when the corresponding match criteria are satisfied. Use deny-first ordering when enforcing strict boundaries with explicit allow exceptions.", "hasChildren": false @@ -46287,7 +50497,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Session Send Rule Match", "help": "Defines optional rule match conditions that can combine channel, chatType, and key-prefix constraints. Keep matches narrow so policy intent stays readable and debugging remains straightforward.", "hasChildren": true @@ -46299,7 +50512,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Session Send Rule Channel", "help": "Matches rule application to a specific channel/provider id (for example discord, telegram, slack). Use this when one channel should permit or deny delivery independently of others.", "hasChildren": false @@ -46311,7 +50527,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Session Send Rule Chat Type", "help": "Matches rule application to chat type (direct, group, thread) so behavior varies by conversation form. Use this when DM and group destinations require different safety boundaries.", "hasChildren": false @@ -46323,7 +50542,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Session Send Rule Key Prefix", "help": "Matches a normalized session-key prefix after internal key normalization steps in policy consumers. Use this for general prefix controls, and prefer rawKeyPrefix when exact full-key matching is required.", "hasChildren": false @@ -46335,7 +50557,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "storage"], + "tags": [ + "access", + "storage" + ], "label": "Session Send Rule Raw Key Prefix", "help": "Matches the raw, unnormalized session-key prefix for exact full-key policy targeting. Use this when normalized keyPrefix is too broad and you need agent-prefixed or transport-specific precision.", "hasChildren": false @@ -46347,7 +50572,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Store Path", "help": "Sets the session storage file path used to persist session records across restarts. Use an explicit path only when you need custom disk layout, backup routing, or mounted-volume storage.", "hasChildren": false @@ -46359,7 +50586,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Thread Bindings", "help": "Shared defaults for thread-bound session routing behavior across providers that support thread focus workflows. Configure global defaults here and override per channel only when behavior differs.", "hasChildren": true @@ -46371,7 +50600,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Thread Binding Enabled", "help": "Global master switch for thread-bound session routing features and focused thread delivery behavior. Keep enabled for modern thread workflows unless you need to disable thread binding globally.", "hasChildren": false @@ -46383,7 +50614,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Thread Binding Idle Timeout (hours)", "help": "Default inactivity window in hours for thread-bound sessions across providers/channels (0 disables idle auto-unfocus). Default: 24.", "hasChildren": false @@ -46395,7 +50628,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "Thread Binding Max Age (hours)", "help": "Optional hard max age in hours for thread-bound sessions across providers/channels (0 disables hard cap). Default: 0.", "hasChildren": false @@ -46407,7 +50643,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage"], + "tags": [ + "performance", + "storage" + ], "label": "Session Typing Interval (seconds)", "help": "Controls interval for repeated typing indicators while replies are being prepared in typing-capable channels. Increase to reduce chatty updates or decrease for more active typing feedback.", "hasChildren": false @@ -46419,7 +50658,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage"], + "tags": [ + "storage" + ], "label": "Session Typing Mode", "help": "Controls typing behavior timing: \"never\", \"instant\", \"thinking\", or \"message\" based emission points. Keep conservative modes in high-volume channels to avoid unnecessary typing noise.", "hasChildren": false @@ -46431,7 +50672,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Skills", "hasChildren": true }, @@ -46478,11 +50721,17 @@ { "path": "skills.entries.*.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security"], + "tags": [ + "auth", + "security" + ], "hasChildren": true }, { @@ -46691,7 +50940,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Watch Skills", "help": "Enable filesystem watching for skill-definition changes so updates can be applied without full process restart. Keep enabled in development workflows and disable in immutable production images.", "hasChildren": false @@ -46703,7 +50954,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation", "performance"], + "tags": [ + "automation", + "performance" + ], "label": "Skills Watch Debounce (ms)", "help": "Debounce window in milliseconds for coalescing rapid skill file changes before reload logic runs. Increase to reduce reload churn on frequent writes, or lower for faster edit feedback.", "hasChildren": false @@ -46715,7 +50969,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Talk", "help": "Talk-mode voice synthesis settings for voice identity, model selection, output format, and interruption behavior. Use this section to tune human-facing voice UX while controlling latency and cost.", "hasChildren": true @@ -46723,11 +50979,18 @@ { "path": "talk.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "media", "security"], + "tags": [ + "auth", + "media", + "security" + ], "label": "Talk API Key", "help": "Use this legacy ElevenLabs API key for Talk mode only during migration, and keep secrets in env-backed storage. Prefer talk.providers.elevenlabs.apiKey (fallback: ELEVENLABS_API_KEY).", "hasChildren": true @@ -46769,7 +51032,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Talk Interrupt on Speech", "help": "If true (default), stop assistant speech when the user starts speaking in Talk mode. Keep enabled for conversational turn-taking.", "hasChildren": false @@ -46781,7 +51046,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "models"], + "tags": [ + "media", + "models" + ], "label": "Talk Model ID", "help": "Legacy ElevenLabs model ID for Talk mode (default: eleven_v3). Prefer talk.providers.elevenlabs.modelId.", "hasChildren": false @@ -46793,7 +51061,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Talk Output Format", "help": "Use this legacy ElevenLabs output format for Talk mode (for example pcm_44100 or mp3_44100_128) only during migration. Prefer talk.providers.elevenlabs.outputFormat.", "hasChildren": false @@ -46805,7 +51075,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Talk Active Provider", "help": "Active Talk provider id (for example \"elevenlabs\").", "hasChildren": false @@ -46817,7 +51089,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Talk Provider Settings", "help": "Provider-specific Talk settings keyed by provider id. During migration, prefer this over legacy talk.* keys.", "hasChildren": true @@ -46844,11 +51118,18 @@ { "path": "talk.providers.*.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "media", "security"], + "tags": [ + "auth", + "media", + "security" + ], "label": "Talk Provider API Key", "help": "Provider API key for Talk mode.", "hasChildren": true @@ -46890,7 +51171,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "models"], + "tags": [ + "media", + "models" + ], "label": "Talk Provider Model ID", "help": "Provider default model ID for Talk mode.", "hasChildren": false @@ -46902,7 +51186,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Talk Provider Output Format", "help": "Provider default output format for Talk mode.", "hasChildren": false @@ -46914,7 +51200,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Talk Provider Voice Aliases", "help": "Optional provider voice alias map for Talk directives.", "hasChildren": true @@ -46936,7 +51224,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Talk Provider Voice ID", "help": "Provider default voice ID for Talk mode.", "hasChildren": false @@ -46948,7 +51238,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance"], + "tags": [ + "media", + "performance" + ], "label": "Talk Silence Timeout (ms)", "help": "Milliseconds of user silence before Talk mode finalizes and sends the current transcript. Leave unset to keep the platform default pause window (700 ms on macOS and Android, 900 ms on iOS).", "hasChildren": false @@ -46960,7 +51253,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Talk Voice Aliases", "help": "Use this legacy ElevenLabs voice alias map (for example {\"Clawd\":\"EXAVITQu4vr4xnSDxMaL\"}) only during migration. Prefer talk.providers.elevenlabs.voiceAliases.", "hasChildren": true @@ -46982,7 +51277,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media"], + "tags": [ + "media" + ], "label": "Talk Voice ID", "help": "Legacy ElevenLabs default voice ID for Talk mode. Prefer talk.providers.elevenlabs.voiceId.", "hasChildren": false @@ -46994,7 +51291,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Tools", "help": "Global tool access policy and capability configuration across web, exec, media, messaging, and elevated surfaces. Use this section to constrain risky capabilities before broad rollout.", "hasChildren": true @@ -47006,7 +51305,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Agent-to-Agent Tool Access", "help": "Policy for allowing agent-to-agent tool calls and constraining which target agents can be reached. Keep disabled or tightly scoped unless cross-agent orchestration is intentionally enabled.", "hasChildren": true @@ -47018,7 +51319,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "tools"], + "tags": [ + "access", + "tools" + ], "label": "Agent-to-Agent Target Allowlist", "help": "Allowlist of target agent IDs permitted for agent_to_agent calls when orchestration is enabled. Use explicit allowlists to avoid uncontrolled cross-agent call graphs.", "hasChildren": true @@ -47040,7 +51344,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Enable Agent-to-Agent Tool", "help": "Enables the agent_to_agent tool surface so one agent can invoke another agent at runtime. Keep off in simple deployments and enable only when orchestration value outweighs complexity.", "hasChildren": false @@ -47052,7 +51358,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "tools"], + "tags": [ + "access", + "tools" + ], "label": "Tool Allowlist", "help": "Absolute tool allowlist that replaces profile-derived defaults for strict environments. Use this only when you intentionally run a tightly curated subset of tool capabilities.", "hasChildren": true @@ -47074,7 +51383,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "tools"], + "tags": [ + "access", + "tools" + ], "label": "Tool Allowlist Additions", "help": "Extra tool allowlist entries merged on top of the selected tool profile and default policy. Keep this list small and explicit so audits can quickly identify intentional policy exceptions.", "hasChildren": true @@ -47096,7 +51408,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Tool Policy by Provider", "help": "Per-provider tool allow/deny overrides keyed by channel/provider ID to tailor capabilities by surface. Use this when one provider needs stricter controls than global tool policy.", "hasChildren": true @@ -47188,7 +51502,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "tools"], + "tags": [ + "access", + "tools" + ], "label": "Tool Denylist", "help": "Global tool denylist that blocks listed tools even when profile or provider rules would allow them. Use deny rules for emergency lockouts and long-term defense-in-depth.", "hasChildren": true @@ -47210,7 +51527,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Elevated Tool Access", "help": "Elevated tool access controls for privileged command surfaces that should only be reachable from trusted senders. Keep disabled unless operator workflows explicitly require elevated actions.", "hasChildren": true @@ -47222,7 +51541,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "tools"], + "tags": [ + "access", + "tools" + ], "label": "Elevated Tool Allow Rules", "help": "Sender allow rules for elevated tools, usually keyed by channel/provider identity formats. Use narrow, explicit identities so elevated commands cannot be triggered by unintended users.", "hasChildren": true @@ -47240,7 +51562,10 @@ { "path": "tools.elevated.allowFrom.*.*", "kind": "core", - "type": ["number", "string"], + "type": [ + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -47254,7 +51579,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Enable Elevated Tool Access", "help": "Enables elevated tool execution path when sender and policy checks pass. Keep disabled in public/shared channels and enable only for trusted owner-operated contexts.", "hasChildren": false @@ -47266,7 +51593,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Exec Tool", "help": "Exec-tool policy grouping for shell execution host, security mode, approval behavior, and runtime bindings. Keep conservative defaults in production and tighten elevated execution paths.", "hasChildren": true @@ -47288,7 +51617,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "tools"], + "tags": [ + "access", + "tools" + ], "label": "apply_patch Model Allowlist", "help": "Optional allowlist of model ids (e.g. \"gpt-5.2\" or \"openai/gpt-5.2\").", "hasChildren": true @@ -47310,7 +51642,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Enable apply_patch", "help": "Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.", "hasChildren": false @@ -47322,7 +51656,12 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "advanced", "security", "tools"], + "tags": [ + "access", + "advanced", + "security", + "tools" + ], "label": "apply_patch Workspace-Only", "help": "Restrict apply_patch paths to the workspace directory (default: true). Set false to allow writing outside the workspace (dangerous).", "hasChildren": false @@ -47332,10 +51671,16 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["off", "on-miss", "always"], + "enumValues": [ + "off", + "on-miss", + "always" + ], "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Exec Ask", "help": "Approval strategy for when exec commands require human confirmation before running. Use stricter ask behavior in shared channels and lower-friction settings in private operator contexts.", "hasChildren": false @@ -47365,10 +51710,16 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["sandbox", "gateway", "node"], + "enumValues": [ + "sandbox", + "gateway", + "node" + ], "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Exec Host", "help": "Selects execution host strategy for shell commands, typically controlling local vs delegated execution environment. Use the safest host mode that still satisfies your automation requirements.", "hasChildren": false @@ -47380,7 +51731,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Exec Node Binding", "help": "Node binding configuration for exec tooling when command execution is delegated through connected nodes. Use explicit node binding only when multi-node routing is required.", "hasChildren": false @@ -47392,7 +51745,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Exec Notify On Exit", "help": "When true (default), backgrounded exec sessions on exit and node exec lifecycle events enqueue a system event and request a heartbeat.", "hasChildren": false @@ -47404,7 +51759,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Exec Notify On Empty Success", "help": "When true, successful backgrounded exec exits with empty output still enqueue a completion system event (default: false).", "hasChildren": false @@ -47416,7 +51773,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage", "tools"], + "tags": [ + "storage", + "tools" + ], "label": "Exec PATH Prepend", "help": "Directories to prepend to PATH for exec runs (gateway/sandbox).", "hasChildren": true @@ -47438,7 +51798,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage", "tools"], + "tags": [ + "storage", + "tools" + ], "label": "Exec Safe Bin Profiles", "help": "Optional per-binary safe-bin profiles (positional limits + allowed/denied flags).", "hasChildren": true @@ -47520,7 +51883,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Exec Safe Bins", "help": "Allow stdin-only safe binaries to run without explicit allowlist entries.", "hasChildren": true @@ -47542,7 +51907,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage", "tools"], + "tags": [ + "storage", + "tools" + ], "label": "Exec Safe Bin Trusted Dirs", "help": "Additional explicit directories trusted for safe-bin path checks (PATH entries are never auto-trusted).", "hasChildren": true @@ -47562,10 +51930,16 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["deny", "allowlist", "full"], + "enumValues": [ + "deny", + "allowlist", + "full" + ], "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Exec Security", "help": "Execution security posture selector controlling sandbox/approval expectations for command execution. Keep strict security mode for untrusted prompts and relax only for trusted operator workflows.", "hasChildren": false @@ -47597,7 +51971,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Workspace-only FS tools", "help": "Restrict filesystem tools (read/write/edit/apply_patch) to the workspace directory (default: false).", "hasChildren": false @@ -47619,7 +51995,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Enable Link Understanding", "help": "Enable automatic link understanding pre-processing so URLs can be summarized before agent reasoning. Keep enabled for richer context, and disable when strict minimal processing is required.", "hasChildren": false @@ -47631,7 +52009,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "tools"], + "tags": [ + "performance", + "tools" + ], "label": "Link Understanding Max Links", "help": "Maximum number of links expanded per turn during link understanding. Use lower values to control latency/cost in chatty threads and higher values when multi-link context is critical.", "hasChildren": false @@ -47643,7 +52024,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models", "tools"], + "tags": [ + "models", + "tools" + ], "label": "Link Understanding Models", "help": "Preferred model list for link understanding tasks, evaluated in order as fallbacks when supported. Use lightweight models first for routine summarization and heavier models only when needed.", "hasChildren": true @@ -47715,7 +52099,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Link Understanding Scope", "help": "Controls when link understanding runs relative to conversation context and message type. Keep scope conservative to avoid unnecessary fetches on messages where links are not actionable.", "hasChildren": true @@ -47817,7 +52203,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "tools"], + "tags": [ + "performance", + "tools" + ], "label": "Link Understanding Timeout (sec)", "help": "Per-link understanding timeout budget in seconds before unresolved links are skipped. Keep this bounded to avoid long stalls when external sites are slow or unreachable.", "hasChildren": false @@ -47839,7 +52228,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Tool-loop Critical Threshold", "help": "Critical threshold for repetitive patterns when detector is enabled (default: 20).", "hasChildren": false @@ -47861,7 +52252,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Tool-loop Generic Repeat Detection", "help": "Enable generic repeated same-tool/same-params loop detection (default: true).", "hasChildren": false @@ -47873,7 +52266,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Tool-loop Poll No-Progress Detection", "help": "Enable known poll tool no-progress loop detection (default: true).", "hasChildren": false @@ -47885,7 +52280,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Tool-loop Ping-Pong Detection", "help": "Enable ping-pong loop detection (default: true).", "hasChildren": false @@ -47897,7 +52294,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Tool-loop Detection", "help": "Enable repetitive tool-call loop detection and backoff safety checks (default: false).", "hasChildren": false @@ -47909,7 +52308,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["reliability", "tools"], + "tags": [ + "reliability", + "tools" + ], "label": "Tool-loop Global Circuit Breaker Threshold", "help": "Global no-progress breaker threshold (default: 30).", "hasChildren": false @@ -47921,7 +52323,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Tool-loop History Size", "help": "Tool history window size for loop detection (default: 30).", "hasChildren": false @@ -47933,7 +52337,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Tool-loop Warning Threshold", "help": "Warning threshold for repetitive patterns when detector is enabled (default: 10).", "hasChildren": false @@ -47965,7 +52371,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Audio Understanding Attachment Policy", "help": "Attachment policy for audio inputs indicating which uploaded files are eligible for audio processing. Keep restrictive defaults in mixed-content channels to avoid unintended audio workloads.", "hasChildren": true @@ -48057,7 +52466,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Transcript Echo Format", "help": "Format string for the echoed transcript message. Use `{transcript}` as a placeholder for the transcribed text. Default: '📝 \"{transcript}\"'.", "hasChildren": false @@ -48069,7 +52481,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Echo Transcript to Chat", "help": "Echo the audio transcript back to the originating chat before agent processing. When enabled, users immediately see what was heard from their voice note, helping them verify transcription accuracy before the agent acts on it. Default: false.", "hasChildren": false @@ -48081,7 +52496,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Enable Audio Understanding", "help": "Enable audio understanding so voice notes or audio clips can be transcribed/summarized for agent context. Disable when audio ingestion is outside policy or unnecessary for your workflows.", "hasChildren": false @@ -48113,7 +52531,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Audio Understanding Language", "help": "Preferred language hint for audio understanding/transcription when provider support is available. Set this to improve recognition accuracy for known primary languages.", "hasChildren": false @@ -48125,7 +52546,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance", "tools"], + "tags": [ + "media", + "performance", + "tools" + ], "label": "Audio Understanding Max Bytes", "help": "Maximum accepted audio payload size in bytes before processing is rejected or clipped by policy. Set this based on expected recording length and upstream provider limits.", "hasChildren": false @@ -48137,7 +52562,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance", "tools"], + "tags": [ + "media", + "performance", + "tools" + ], "label": "Audio Understanding Max Chars", "help": "Maximum characters retained from audio understanding output to prevent oversized transcript injection. Increase for long-form dictation, or lower to keep conversational turns compact.", "hasChildren": false @@ -48149,7 +52578,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "models", "tools"], + "tags": [ + "media", + "models", + "tools" + ], "label": "Audio Understanding Models", "help": "Ordered model preferences specifically for audio understanding, used before shared media model fallback. Choose models optimized for transcription quality in your primary language/domain.", "hasChildren": true @@ -48387,7 +52820,11 @@ { "path": "tools.media.audio.models.*.providerOptions.*.*", "kind": "core", - "type": ["boolean", "number", "string"], + "type": [ + "boolean", + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -48421,7 +52858,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Audio Understanding Prompt", "help": "Instruction template guiding audio understanding output style, such as concise summary versus near-verbatim transcript. Keep wording consistent so downstream automations can rely on output format.", "hasChildren": false @@ -48449,7 +52889,11 @@ { "path": "tools.media.audio.providerOptions.*.*", "kind": "core", - "type": ["boolean", "number", "string"], + "type": [ + "boolean", + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -48463,7 +52907,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Audio Understanding Scope", "help": "Scope selector for when audio understanding runs across inbound messages and attachments. Keep focused scopes in high-volume channels to reduce cost and avoid accidental transcription.", "hasChildren": true @@ -48565,7 +53012,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance", "tools"], + "tags": [ + "media", + "performance", + "tools" + ], "label": "Audio Understanding Timeout (sec)", "help": "Timeout in seconds for audio understanding execution before the operation is cancelled. Use longer timeouts for long recordings and tighter ones for interactive chat responsiveness.", "hasChildren": false @@ -48577,7 +53028,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance", "tools"], + "tags": [ + "media", + "performance", + "tools" + ], "label": "Media Understanding Concurrency", "help": "Maximum number of concurrent media understanding operations per turn across image, audio, and video tasks. Lower this in resource-constrained deployments to prevent CPU/network saturation.", "hasChildren": false @@ -48599,7 +53054,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Image Understanding Attachment Policy", "help": "Attachment handling policy for image inputs, including which message attachments qualify for image analysis. Use restrictive settings in untrusted channels to reduce unexpected processing.", "hasChildren": true @@ -48711,7 +53169,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Enable Image Understanding", "help": "Enable image understanding so attached or referenced images can be interpreted into textual context. Disable if you need text-only operation or want to avoid image-processing cost.", "hasChildren": false @@ -48753,7 +53214,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance", "tools"], + "tags": [ + "media", + "performance", + "tools" + ], "label": "Image Understanding Max Bytes", "help": "Maximum accepted image payload size in bytes before the item is skipped or truncated by policy. Keep limits realistic for your provider caps and infrastructure bandwidth.", "hasChildren": false @@ -48765,7 +53230,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance", "tools"], + "tags": [ + "media", + "performance", + "tools" + ], "label": "Image Understanding Max Chars", "help": "Maximum characters returned from image understanding output after model response normalization. Use tighter limits to reduce prompt bloat and larger limits for detail-heavy OCR tasks.", "hasChildren": false @@ -48777,7 +53246,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "models", "tools"], + "tags": [ + "media", + "models", + "tools" + ], "label": "Image Understanding Models", "help": "Ordered model preferences specifically for image understanding when you want to override shared media models. Put the most reliable multimodal model first to reduce fallback attempts.", "hasChildren": true @@ -49015,7 +53488,11 @@ { "path": "tools.media.image.models.*.providerOptions.*.*", "kind": "core", - "type": ["boolean", "number", "string"], + "type": [ + "boolean", + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -49049,7 +53526,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Image Understanding Prompt", "help": "Instruction template used for image understanding requests to shape extraction style and detail level. Keep prompts deterministic so outputs stay consistent across turns and channels.", "hasChildren": false @@ -49077,7 +53557,11 @@ { "path": "tools.media.image.providerOptions.*.*", "kind": "core", - "type": ["boolean", "number", "string"], + "type": [ + "boolean", + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -49091,7 +53575,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Image Understanding Scope", "help": "Scope selector for when image understanding is attempted (for example only explicit requests versus broader auto-detection). Keep narrow scope in busy channels to control token and API spend.", "hasChildren": true @@ -49193,7 +53680,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance", "tools"], + "tags": [ + "media", + "performance", + "tools" + ], "label": "Image Understanding Timeout (sec)", "help": "Timeout in seconds for each image understanding request before it is aborted. Increase for high-resolution analysis and lower it for latency-sensitive operator workflows.", "hasChildren": false @@ -49205,7 +53696,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "models", "tools"], + "tags": [ + "media", + "models", + "tools" + ], "label": "Media Understanding Shared Models", "help": "Shared fallback model list used by media understanding tools when modality-specific model lists are not set. Keep this aligned with available multimodal providers to avoid runtime fallback churn.", "hasChildren": true @@ -49443,7 +53938,11 @@ { "path": "tools.media.models.*.providerOptions.*.*", "kind": "core", - "type": ["boolean", "number", "string"], + "type": [ + "boolean", + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -49487,7 +53986,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Video Understanding Attachment Policy", "help": "Attachment eligibility policy for video analysis, defining which message files can trigger video processing. Keep this explicit in shared channels to prevent accidental large media workloads.", "hasChildren": true @@ -49599,7 +54101,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Enable Video Understanding", "help": "Enable video understanding so clips can be summarized into text for downstream reasoning and responses. Disable when processing video is out of policy or too expensive for your deployment.", "hasChildren": false @@ -49641,7 +54146,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance", "tools"], + "tags": [ + "media", + "performance", + "tools" + ], "label": "Video Understanding Max Bytes", "help": "Maximum accepted video payload size in bytes before policy rejection or trimming occurs. Tune this to provider and infrastructure limits to avoid repeated timeout/failure loops.", "hasChildren": false @@ -49653,7 +54162,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance", "tools"], + "tags": [ + "media", + "performance", + "tools" + ], "label": "Video Understanding Max Chars", "help": "Maximum characters retained from video understanding output to control prompt growth. Raise for dense scene descriptions and lower when concise summaries are preferred.", "hasChildren": false @@ -49665,7 +54178,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "models", "tools"], + "tags": [ + "media", + "models", + "tools" + ], "label": "Video Understanding Models", "help": "Ordered model preferences specifically for video understanding before shared media fallback applies. Prioritize models with strong multimodal video support to minimize degraded summaries.", "hasChildren": true @@ -49903,7 +54420,11 @@ { "path": "tools.media.video.models.*.providerOptions.*.*", "kind": "core", - "type": ["boolean", "number", "string"], + "type": [ + "boolean", + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -49937,7 +54458,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Video Understanding Prompt", "help": "Instruction template for video understanding describing desired summary granularity and focus areas. Keep this stable so output quality remains predictable across model/provider fallbacks.", "hasChildren": false @@ -49965,7 +54489,11 @@ { "path": "tools.media.video.providerOptions.*.*", "kind": "core", - "type": ["boolean", "number", "string"], + "type": [ + "boolean", + "number", + "string" + ], "required": false, "deprecated": false, "sensitive": false, @@ -49979,7 +54507,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "tools"], + "tags": [ + "media", + "tools" + ], "label": "Video Understanding Scope", "help": "Scope selector controlling when video understanding is attempted across incoming events. Narrow scope in noisy channels, and broaden only where video interpretation is core to workflow.", "hasChildren": true @@ -50081,7 +54612,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["media", "performance", "tools"], + "tags": [ + "media", + "performance", + "tools" + ], "label": "Video Understanding Timeout (sec)", "help": "Timeout in seconds for each video understanding request before cancellation. Use conservative values in interactive channels and longer values for offline or batch-heavy processing.", "hasChildren": false @@ -50103,7 +54638,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "tools"], + "tags": [ + "access", + "tools" + ], "label": "Allow Cross-Context Messaging", "help": "Legacy override: allow cross-context sends across all providers.", "hasChildren": false @@ -50125,7 +54663,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Enable Message Broadcast", "help": "Enable broadcast action (default: true).", "hasChildren": false @@ -50147,7 +54687,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "tools"], + "tags": [ + "access", + "tools" + ], "label": "Allow Cross-Context (Across Providers)", "help": "Allow sends across different providers (default: false).", "hasChildren": false @@ -50159,7 +54702,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["access", "tools"], + "tags": [ + "access", + "tools" + ], "label": "Allow Cross-Context (Same Provider)", "help": "Allow sends to other channels within the same provider (default: true).", "hasChildren": false @@ -50181,7 +54727,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Cross-Context Marker", "help": "Add a visible origin marker when sending cross-context (default: true).", "hasChildren": false @@ -50193,7 +54741,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Cross-Context Marker Prefix", "help": "Text prefix for cross-context markers (supports \"{channel}\").", "hasChildren": false @@ -50205,7 +54755,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Cross-Context Marker Suffix", "help": "Text suffix for cross-context markers (supports \"{channel}\").", "hasChildren": false @@ -50217,7 +54769,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage", "tools"], + "tags": [ + "storage", + "tools" + ], "label": "Tool Profile", "help": "Global tool profile name used to select a predefined tool policy baseline before applying allow/deny overrides. Use this for consistent environment posture across agents and keep profile names stable.", "hasChildren": false @@ -50229,7 +54784,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage", "tools"], + "tags": [ + "storage", + "tools" + ], "label": "Sandbox Tool Policy", "help": "Tool policy wrapper for sandboxed agent executions so sandbox runs can have distinct capability boundaries. Use this to enforce stronger safety in sandbox contexts.", "hasChildren": true @@ -50241,7 +54799,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["storage", "tools"], + "tags": [ + "storage", + "tools" + ], "label": "Sandbox Tool Allow/Deny Policy", "help": "Allow/deny tool policy applied when agents run in sandboxed execution environments. Keep policies minimal so sandbox tasks cannot escalate into unnecessary external actions.", "hasChildren": true @@ -50391,10 +54952,18 @@ "kind": "core", "type": "string", "required": false, - "enumValues": ["self", "tree", "agent", "all"], + "enumValues": [ + "self", + "tree", + "agent", + "all" + ], "deprecated": false, "sensitive": false, - "tags": ["storage", "tools"], + "tags": [ + "storage", + "tools" + ], "label": "Session Tools Visibility", "help": "Controls which sessions can be targeted by sessions_list/sessions_history/sessions_send. (\"tree\" default = current session + spawned subagent sessions; \"self\" = only current; \"agent\" = any session in the current agent id; \"all\" = any session; cross-agent still requires tools.agentToAgent).", "hasChildren": false @@ -50406,7 +54975,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Subagent Tool Policy", "help": "Tool policy wrapper for spawned subagents to restrict or expand tool availability compared to parent defaults. Use this to keep delegated agent capabilities scoped to task intent.", "hasChildren": true @@ -50418,7 +54989,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Subagent Tool Allow/Deny Policy", "help": "Allow/deny tool policy applied to spawned subagent runtimes for per-subagent hardening. Keep this narrower than parent scope when subagents run semi-autonomous workflows.", "hasChildren": true @@ -50490,7 +55063,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Web Tools", "help": "Web-tool policy grouping for search/fetch providers, limits, and fallback behavior tuning. Keep enabled settings aligned with API key availability and outbound networking policy.", "hasChildren": true @@ -50512,7 +55087,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage", "tools"], + "tags": [ + "performance", + "storage", + "tools" + ], "label": "Web Fetch Cache TTL (min)", "help": "Cache TTL in minutes for web_fetch results.", "hasChildren": false @@ -50524,7 +55103,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Enable Web Fetch Tool", "help": "Enable the web_fetch tool (lightweight HTTP fetch).", "hasChildren": false @@ -50542,11 +55123,18 @@ { "path": "tools.web.fetch.firecrawl.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security", "tools"], + "tags": [ + "auth", + "security", + "tools" + ], "label": "Firecrawl API Key", "help": "Firecrawl API key (fallback: FIRECRAWL_API_KEY env var).", "hasChildren": true @@ -50588,7 +55176,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Firecrawl Base URL", "help": "Firecrawl base URL (e.g. https://api.firecrawl.dev or custom endpoint).", "hasChildren": false @@ -50600,7 +55190,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Enable Firecrawl Fallback", "help": "Enable Firecrawl fallback for web_fetch (if configured).", "hasChildren": false @@ -50612,7 +55204,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "tools"], + "tags": [ + "performance", + "tools" + ], "label": "Firecrawl Cache Max Age (ms)", "help": "Firecrawl maxAge (ms) for cached results when supported by the API.", "hasChildren": false @@ -50624,7 +55219,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Firecrawl Main Content Only", "help": "When true, Firecrawl returns only the main content (default: true).", "hasChildren": false @@ -50636,7 +55233,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "tools"], + "tags": [ + "performance", + "tools" + ], "label": "Firecrawl Timeout (sec)", "help": "Timeout in seconds for Firecrawl requests.", "hasChildren": false @@ -50648,7 +55248,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "tools"], + "tags": [ + "performance", + "tools" + ], "label": "Web Fetch Max Chars", "help": "Max characters returned by web_fetch (truncated).", "hasChildren": false @@ -50660,7 +55263,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "tools"], + "tags": [ + "performance", + "tools" + ], "label": "Web Fetch Hard Max Chars", "help": "Hard cap for web_fetch maxChars (applies to config and tool calls).", "hasChildren": false @@ -50672,7 +55278,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage", "tools"], + "tags": [ + "performance", + "storage", + "tools" + ], "label": "Web Fetch Max Redirects", "help": "Maximum redirects allowed for web_fetch (default: 3).", "hasChildren": false @@ -50684,7 +55294,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Web Fetch Readability Extraction", "help": "Use Readability to extract main content from HTML (fallbacks to basic HTML cleanup).", "hasChildren": false @@ -50696,7 +55308,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "tools"], + "tags": [ + "performance", + "tools" + ], "label": "Web Fetch Timeout (sec)", "help": "Timeout in seconds for web_fetch requests.", "hasChildren": false @@ -50708,7 +55323,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Web Fetch User-Agent", "help": "Override User-Agent header for web_fetch requests.", "hasChildren": false @@ -50726,11 +55343,18 @@ { "path": "tools.web.search.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security", "tools"], + "tags": [ + "auth", + "security", + "tools" + ], "label": "Brave Search API Key", "help": "Brave Search API key (fallback: BRAVE_API_KEY env var).", "hasChildren": true @@ -50782,7 +55406,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Brave Search Mode", "help": "Brave Search mode: \"web\" (URL results) or \"llm-context\" (pre-extracted page content for LLM grounding).", "hasChildren": false @@ -50794,7 +55420,11 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "storage", "tools"], + "tags": [ + "performance", + "storage", + "tools" + ], "label": "Web Search Cache TTL (min)", "help": "Cache TTL in minutes for web_search results.", "hasChildren": false @@ -50806,7 +55436,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Enable Web Search Tool", "help": "Enable the web_search tool (requires a provider API key).", "hasChildren": false @@ -50824,11 +55456,18 @@ { "path": "tools.web.search.gemini.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security", "tools"], + "tags": [ + "auth", + "security", + "tools" + ], "label": "Gemini Search API Key", "help": "Gemini API key for Google Search grounding (fallback: GEMINI_API_KEY env var).", "hasChildren": true @@ -50870,7 +55509,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models", "tools"], + "tags": [ + "models", + "tools" + ], "label": "Gemini Search Model", "help": "Gemini model override (default: \"gemini-2.5-flash\").", "hasChildren": false @@ -50888,11 +55530,18 @@ { "path": "tools.web.search.grok.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security", "tools"], + "tags": [ + "auth", + "security", + "tools" + ], "label": "Grok Search API Key", "help": "Grok (xAI) API key (fallback: XAI_API_KEY env var).", "hasChildren": true @@ -50944,7 +55593,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models", "tools"], + "tags": [ + "models", + "tools" + ], "label": "Grok Search Model", "help": "Grok model override (default: \"grok-4-1-fast\").", "hasChildren": false @@ -50962,11 +55614,18 @@ { "path": "tools.web.search.kimi.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security", "tools"], + "tags": [ + "auth", + "security", + "tools" + ], "label": "Kimi Search API Key", "help": "Moonshot/Kimi API key (fallback: KIMI_API_KEY or MOONSHOT_API_KEY env var).", "hasChildren": true @@ -51008,7 +55667,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Kimi Search Base URL", "help": "Kimi base URL override (default: \"https://api.moonshot.ai/v1\").", "hasChildren": false @@ -51020,7 +55681,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models", "tools"], + "tags": [ + "models", + "tools" + ], "label": "Kimi Search Model", "help": "Kimi model override (default: \"moonshot-v1-128k\").", "hasChildren": false @@ -51032,7 +55696,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "tools"], + "tags": [ + "performance", + "tools" + ], "label": "Web Search Max Results", "help": "Number of results to return (1-10).", "hasChildren": false @@ -51050,11 +55717,18 @@ { "path": "tools.web.search.perplexity.apiKey", "kind": "core", - "type": ["object", "string"], + "type": [ + "object", + "string" + ], "required": false, "deprecated": false, "sensitive": true, - "tags": ["auth", "security", "tools"], + "tags": [ + "auth", + "security", + "tools" + ], "label": "Perplexity API Key", "help": "Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var). Direct Perplexity keys default to the Search API; OpenRouter keys use Sonar chat completions.", "hasChildren": true @@ -51096,7 +55770,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Perplexity Base URL", "help": "Optional Perplexity/OpenRouter chat-completions base URL override. Setting this opts Perplexity into the legacy Sonar/OpenRouter compatibility path.", "hasChildren": false @@ -51108,7 +55784,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["models", "tools"], + "tags": [ + "models", + "tools" + ], "label": "Perplexity Model", "help": "Optional Sonar/OpenRouter model override (default: \"perplexity/sonar-pro\"). Setting this opts Perplexity into the legacy chat-completions compatibility path.", "hasChildren": false @@ -51120,7 +55799,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["tools"], + "tags": [ + "tools" + ], "label": "Web Search Provider", "help": "Search provider (\"brave\", \"gemini\", \"grok\", \"kimi\", or \"perplexity\"). Auto-detected from available API keys if omitted.", "hasChildren": false @@ -51132,7 +55813,10 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance", "tools"], + "tags": [ + "performance", + "tools" + ], "label": "Web Search Timeout (sec)", "help": "Timeout in seconds for web_search requests.", "hasChildren": false @@ -51144,7 +55828,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "UI", "help": "UI presentation settings for accenting and assistant identity shown in control surfaces. Use this for branding and readability customization without changing runtime behavior.", "hasChildren": true @@ -51156,7 +55842,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Assistant Appearance", "help": "Assistant display identity settings for name and avatar shown in UI surfaces. Keep these values aligned with your operator-facing persona and support expectations.", "hasChildren": true @@ -51168,7 +55856,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Assistant Avatar", "help": "Assistant avatar image source used in UI surfaces (URL, path, or data URI depending on runtime support). Use trusted assets and consistent branding dimensions for clean rendering.", "hasChildren": false @@ -51180,7 +55870,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Assistant Name", "help": "Display name shown for the assistant in UI views, chat chrome, and status contexts. Keep this stable so operators can reliably identify which assistant persona is active.", "hasChildren": false @@ -51192,7 +55884,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Accent Color", "help": "Primary accent/seam color used by UI surfaces for emphasis, badges, and visual identity cues. Use high-contrast values that remain readable across light/dark themes.", "hasChildren": false @@ -51204,7 +55898,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Updates", "help": "Update-channel and startup-check behavior for keeping OpenClaw runtime versions current. Use conservative channels in production and more experimental channels only in controlled environments.", "hasChildren": true @@ -51226,7 +55922,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Auto Update Beta Check Interval (hours)", "help": "How often beta-channel checks run in hours (default: 1).", "hasChildren": false @@ -51238,7 +55936,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Auto Update Enabled", "help": "Enable background auto-update for package installs (default: false).", "hasChildren": false @@ -51250,7 +55950,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Auto Update Stable Delay (hours)", "help": "Minimum delay before stable-channel auto-apply starts (default: 6).", "hasChildren": false @@ -51262,7 +55964,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Auto Update Stable Jitter (hours)", "help": "Extra stable-channel rollout spread window in hours (default: 12).", "hasChildren": false @@ -51274,7 +55978,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Update Channel", "help": "Update channel for git + npm installs (\"stable\", \"beta\", or \"dev\").", "hasChildren": false @@ -51286,7 +55992,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], + "tags": [ + "automation" + ], "label": "Update Check on Start", "help": "Check for npm updates when the gateway starts (default: true).", "hasChildren": false @@ -51298,7 +56006,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Web Channel", "help": "Web channel runtime settings for heartbeat and reconnect behavior when operating web-based chat surfaces. Use reconnect values tuned to your network reliability profile and expected uptime needs.", "hasChildren": true @@ -51310,7 +56020,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Web Channel Enabled", "help": "Enables the web channel runtime and related websocket lifecycle behavior. Keep disabled when web chat is unused to reduce active connection management overhead.", "hasChildren": false @@ -51322,7 +56034,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["automation"], + "tags": [ + "automation" + ], "label": "Web Channel Heartbeat Interval (sec)", "help": "Heartbeat interval in seconds for web channel connectivity and liveness maintenance. Use shorter intervals for faster detection, or longer intervals to reduce keepalive chatter.", "hasChildren": false @@ -51334,7 +56048,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Web Channel Reconnect Policy", "help": "Reconnect backoff policy for web channel reconnect attempts after transport failure. Keep bounded retries and jitter tuned to avoid thundering-herd reconnect behavior.", "hasChildren": true @@ -51346,7 +56062,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Web Reconnect Backoff Factor", "help": "Exponential backoff multiplier used between reconnect attempts in web channel retry loops. Keep factor above 1 and tune with jitter for stable large-fleet reconnect behavior.", "hasChildren": false @@ -51358,7 +56076,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Web Reconnect Initial Delay (ms)", "help": "Initial reconnect delay in milliseconds before the first retry after disconnection. Use modest delays to recover quickly without immediate retry storms.", "hasChildren": false @@ -51370,7 +56090,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Web Reconnect Jitter", "help": "Randomization factor (0-1) applied to reconnect delays to desynchronize clients after outage events. Keep non-zero jitter in multi-client deployments to reduce synchronized spikes.", "hasChildren": false @@ -51382,7 +56104,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Web Reconnect Max Attempts", "help": "Maximum reconnect attempts before giving up for the current failure sequence (0 means no retries). Use finite caps for controlled failure handling in automation-sensitive environments.", "hasChildren": false @@ -51394,7 +56118,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["performance"], + "tags": [ + "performance" + ], "label": "Web Reconnect Max Delay (ms)", "help": "Maximum reconnect backoff cap in milliseconds to bound retry delay growth over repeated failures. Use a reasonable cap so recovery remains timely after prolonged outages.", "hasChildren": false @@ -51406,7 +56132,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Setup Wizard State", "help": "Setup wizard state tracking fields that record the most recent guided onboarding run details. Keep these fields for observability and troubleshooting of setup flows across upgrades.", "hasChildren": true @@ -51418,7 +56146,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Wizard Last Run Timestamp", "help": "ISO timestamp for when the setup wizard most recently completed on this host. Use this to confirm onboarding recency during support and operational audits.", "hasChildren": false @@ -51430,7 +56160,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Wizard Last Run Command", "help": "Command invocation recorded for the latest wizard run to preserve execution context. Use this to reproduce onboarding steps when verifying setup regressions.", "hasChildren": false @@ -51442,7 +56174,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Wizard Last Run Commit", "help": "Source commit identifier recorded for the last wizard execution in development builds. Use this to correlate onboarding behavior with exact source state during debugging.", "hasChildren": false @@ -51454,7 +56188,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Wizard Last Run Mode", "help": "Wizard execution mode recorded as \"local\" or \"remote\" for the most recent onboarding flow. Use this to understand whether setup targeted direct local runtime or remote gateway topology.", "hasChildren": false @@ -51466,7 +56202,9 @@ "required": false, "deprecated": false, "sensitive": false, - "tags": ["advanced"], + "tags": [ + "advanced" + ], "label": "Wizard Last Run Version", "help": "OpenClaw version recorded at the time of the most recent wizard run on this config. Use this when diagnosing behavior differences across version-to-version onboarding changes.", "hasChildren": false From 5a7aba94a2bf1d0ca5aabd86337259a8001c8f6a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 14:18:12 -0700 Subject: [PATCH 024/943] CLI: support package-manager installs from GitHub main (#47630) * CLI: resolve package-manager main install specs * CLI: skip registry resolution for raw package specs * CLI: support main package target updates * CLI: document package update specs in help * Tests: cover package install spec resolution * Tests: cover npm main-package updates * Tests: cover update --tag main * Installer: support main package targets * Installer: support main package targets on Windows * Docs: document package-manager main updates * Docs: document installer main targets * Docs: document npm and pnpm main installs * Docs: document update --tag main * Changelog: note package-manager main installs * Update src/infra/update-global.test.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/cli/update.md | 3 +- docs/install/index.md | 10 ++++ docs/install/installer.md | 82 ++++++++++++++++------------ docs/install/updating.md | 20 ++++++- scripts/install.ps1 | 40 ++++++++++++-- scripts/install.sh | 52 +++++++++++++++--- src/cli/update-cli.test.ts | 42 ++++++++++++++ src/cli/update-cli.ts | 8 ++- src/cli/update-cli/shared.ts | 4 ++ src/cli/update-cli/update-command.ts | 31 ++++++++--- src/infra/update-global.test.ts | 38 +++++++++++++ src/infra/update-global.ts | 38 ++++++++++++- src/infra/update-runner.test.ts | 14 +++++ 14 files changed, 320 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b4546d49d2..bf37c1757e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob. - Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280. - Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898. +- Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs. ### Fixes diff --git a/docs/cli/update.md b/docs/cli/update.md index 7a1840096f2..d1c61518b0c 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -21,6 +21,7 @@ openclaw update wizard openclaw update --channel beta openclaw update --channel dev openclaw update --tag beta +openclaw update --tag main openclaw update --dry-run openclaw update --no-restart openclaw update --json @@ -31,7 +32,7 @@ openclaw --update - `--no-restart`: skip restarting the Gateway service after a successful update. - `--channel `: set the update channel (git + npm; persisted in config). -- `--tag `: override the npm dist-tag or version for this update only. +- `--tag `: override the package target for this update only. For package installs, `main` maps to `github:openclaw/openclaw#main`. - `--dry-run`: preview planned update actions (channel/tag/target/restart flow) without writing config, installing, syncing plugins, or restarting. - `--json`: print machine-readable `UpdateRunResult` JSON. - `--timeout `: per-step timeout (default is 1200s). diff --git a/docs/install/index.md b/docs/install/index.md index d0f847838d0..464a457a360 100644 --- a/docs/install/index.md +++ b/docs/install/index.md @@ -102,6 +102,16 @@ For VPS/cloud hosts, avoid third-party "1-click" marketplace images when possibl + Want the current GitHub `main` head with a package-manager install? + + ```bash + npm install -g github:openclaw/openclaw#main + ``` + + ```bash + pnpm add -g github:openclaw/openclaw#main + ``` + diff --git a/docs/install/installer.md b/docs/install/installer.md index 6317e8e06cc..5859c22fd0d 100644 --- a/docs/install/installer.md +++ b/docs/install/installer.md @@ -116,6 +116,11 @@ The script exits with code `2` for invalid method selection or invalid `--instal curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --install-method git ``` + + ```bash + curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --version main + ``` + ```bash curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --dry-run @@ -126,39 +131,39 @@ The script exits with code `2` for invalid method selection or invalid `--instal -| Flag | Description | -| ------------------------------- | ---------------------------------------------------------- | -| `--install-method npm\|git` | Choose install method (default: `npm`). Alias: `--method` | -| `--npm` | Shortcut for npm method | -| `--git` | Shortcut for git method. Alias: `--github` | -| `--version ` | npm version or dist-tag (default: `latest`) | -| `--beta` | Use beta dist-tag if available, else fallback to `latest` | -| `--git-dir ` | Checkout directory (default: `~/openclaw`). Alias: `--dir` | -| `--no-git-update` | Skip `git pull` for existing checkout | -| `--no-prompt` | Disable prompts | -| `--no-onboard` | Skip onboarding | -| `--onboard` | Enable onboarding | -| `--dry-run` | Print actions without applying changes | -| `--verbose` | Enable debug output (`set -x`, npm notice-level logs) | -| `--help` | Show usage (`-h`) | +| Flag | Description | +| ------------------------------------- | ---------------------------------------------------------- | +| `--install-method npm\|git` | Choose install method (default: `npm`). Alias: `--method` | +| `--npm` | Shortcut for npm method | +| `--git` | Shortcut for git method. Alias: `--github` | +| `--version ` | npm version, dist-tag, or package spec (default: `latest`) | +| `--beta` | Use beta dist-tag if available, else fallback to `latest` | +| `--git-dir ` | Checkout directory (default: `~/openclaw`). Alias: `--dir` | +| `--no-git-update` | Skip `git pull` for existing checkout | +| `--no-prompt` | Disable prompts | +| `--no-onboard` | Skip onboarding | +| `--onboard` | Enable onboarding | +| `--dry-run` | Print actions without applying changes | +| `--verbose` | Enable debug output (`set -x`, npm notice-level logs) | +| `--help` | Show usage (`-h`) | -| Variable | Description | -| ------------------------------------------- | --------------------------------------------- | -| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method | -| `OPENCLAW_VERSION=latest\|next\|` | npm version or dist-tag | -| `OPENCLAW_BETA=0\|1` | Use beta if available | -| `OPENCLAW_GIT_DIR=` | Checkout directory | -| `OPENCLAW_GIT_UPDATE=0\|1` | Toggle git updates | -| `OPENCLAW_NO_PROMPT=1` | Disable prompts | -| `OPENCLAW_NO_ONBOARD=1` | Skip onboarding | -| `OPENCLAW_DRY_RUN=1` | Dry run mode | -| `OPENCLAW_VERBOSE=1` | Debug mode | -| `OPENCLAW_NPM_LOGLEVEL=error\|warn\|notice` | npm log level | -| `SHARP_IGNORE_GLOBAL_LIBVIPS=0\|1` | Control sharp/libvips behavior (default: `1`) | +| Variable | Description | +| ------------------------------------------------------- | --------------------------------------------- | +| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method | +| `OPENCLAW_VERSION=latest\|next\|main\|\|` | npm version, dist-tag, or package spec | +| `OPENCLAW_BETA=0\|1` | Use beta if available | +| `OPENCLAW_GIT_DIR=` | Checkout directory | +| `OPENCLAW_GIT_UPDATE=0\|1` | Toggle git updates | +| `OPENCLAW_NO_PROMPT=1` | Disable prompts | +| `OPENCLAW_NO_ONBOARD=1` | Skip onboarding | +| `OPENCLAW_DRY_RUN=1` | Dry run mode | +| `OPENCLAW_VERBOSE=1` | Debug mode | +| `OPENCLAW_NPM_LOGLEVEL=error\|warn\|notice` | npm log level | +| `SHARP_IGNORE_GLOBAL_LIBVIPS=0\|1` | Control sharp/libvips behavior (default: `1`) | @@ -276,6 +281,11 @@ Designed for environments where you want everything under a local prefix (defaul & ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -InstallMethod git ``` + + ```powershell + & ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -Tag main + ``` + ```powershell & ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -InstallMethod git -GitDir "C:\openclaw" @@ -299,14 +309,14 @@ Designed for environments where you want everything under a local prefix (defaul -| Flag | Description | -| ------------------------- | ------------------------------------------------------ | -| `-InstallMethod npm\|git` | Install method (default: `npm`) | -| `-Tag ` | npm dist-tag (default: `latest`) | -| `-GitDir ` | Checkout directory (default: `%USERPROFILE%\openclaw`) | -| `-NoOnboard` | Skip onboarding | -| `-NoGitUpdate` | Skip `git pull` | -| `-DryRun` | Print actions only | +| Flag | Description | +| --------------------------- | ---------------------------------------------------------- | +| `-InstallMethod npm\|git` | Install method (default: `npm`) | +| `-Tag ` | npm dist-tag, version, or package spec (default: `latest`) | +| `-GitDir ` | Checkout directory (default: `%USERPROFILE%\openclaw`) | +| `-NoOnboard` | Skip onboarding | +| `-NoGitUpdate` | Skip `git pull` | +| `-DryRun` | Print actions only | diff --git a/docs/install/updating.md b/docs/install/updating.md index f94c2600776..e304fe0322b 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -65,7 +65,25 @@ openclaw update --channel dev openclaw update --channel stable ``` -Use `--tag ` for a one-off install tag/version. +Use `--tag ` for a one-off package target override. + +For the current GitHub `main` head via a package-manager install: + +```bash +openclaw update --tag main +``` + +Manual equivalents: + +```bash +npm i -g github:openclaw/openclaw#main +``` + +```bash +pnpm add -g github:openclaw/openclaw#main +``` + +You can also pass an explicit package spec to `--tag` for one-off updates (for example a GitHub ref or tarball URL). See [Development channels](/install/development-channels) for channel semantics and release notes. diff --git a/scripts/install.ps1 b/scripts/install.ps1 index ac30daf9cb5..fccf2fec06b 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -200,13 +200,15 @@ function Ensure-Git { } function Install-OpenClawNpm { - param([string]$Version = "latest") + param([string]$Target = "latest") + + $installSpec = Resolve-PackageInstallSpec -Target $Target - Write-Host "Installing OpenClaw (openclaw@$Version)..." -Level info + Write-Host "Installing OpenClaw ($installSpec)..." -Level info try { # Use -ExecutionPolicy Bypass to handle restricted execution policy - npm install -g openclaw@$Version --no-fund --no-audit 2>&1 + npm install -g $installSpec --no-fund --no-audit 2>&1 Write-Host "OpenClaw installed" -Level success return $true } catch { @@ -257,6 +259,34 @@ node "%~dp0..\openclaw\dist\entry.js" %* return $true } +function Test-ExplicitPackageInstallSpec { + param([string]$Target) + + if ([string]::IsNullOrWhiteSpace($Target)) { + return $false + } + + return $Target.Contains("://") -or + $Target.Contains("#") -or + $Target -match '^(file|github|git\+ssh|git\+https|git\+http|git\+file|npm):' +} + +function Resolve-PackageInstallSpec { + param([string]$Target = "latest") + + $trimmed = $Target.Trim() + if ([string]::IsNullOrWhiteSpace($trimmed)) { + return "openclaw@latest" + } + if ($trimmed.ToLowerInvariant() -eq "main") { + return "github:openclaw/openclaw#main" + } + if (Test-ExplicitPackageInstallSpec -Target $trimmed) { + return $trimmed + } + return "openclaw@$trimmed" +} + function Add-ToPath { param([string]$Path) @@ -301,9 +331,9 @@ function Main { } if ($DryRun) { - Write-Host "[DRY RUN] Would install OpenClaw via npm (tag: $Tag)" -Level info + Write-Host "[DRY RUN] Would install OpenClaw via npm ($((Resolve-PackageInstallSpec -Target $Tag)))" -Level info } else { - if (!(Install-OpenClawNpm -Version $Tag)) { + if (!(Install-OpenClawNpm -Target $Tag)) { exit 1 } } diff --git a/scripts/install.sh b/scripts/install.sh index 2abfbad9935..70c68bf703c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1011,7 +1011,7 @@ Options: --install-method, --method npm|git Install via npm (default) or from a git checkout --npm Shortcut for --install-method npm --git, --github Shortcut for --install-method git - --version npm install: version (default: latest) + --version npm install target (default: latest; use "main" for GitHub main) --beta Use beta if available, else latest --git-dir, --dir Checkout directory (default: ~/openclaw) --no-git-update Skip git pull for existing checkout @@ -1024,7 +1024,7 @@ Options: Environment variables: OPENCLAW_INSTALL_METHOD=git|npm - OPENCLAW_VERSION=latest|next| + OPENCLAW_VERSION=latest|next|main|| OPENCLAW_BETA=0|1 OPENCLAW_GIT_DIR=... OPENCLAW_GIT_UPDATE=0|1 @@ -1040,6 +1040,7 @@ Examples: curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --no-onboard curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --no-onboard --verify + curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --version main curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --install-method git --no-onboard EOF } @@ -1963,6 +1964,43 @@ resolve_beta_version() { echo "$beta" } +is_explicit_package_install_spec() { + local value="${1:-}" + [[ "$value" == *"://"* || "$value" == *"#"* || "$value" =~ ^(file|github|git\+ssh|git\+https|git\+http|git\+file|npm): ]] +} + +can_resolve_registry_package_version() { + local value="${1:-}" + if [[ -z "$value" ]]; then + return 0 + fi + if [[ "${value,,}" == "main" ]]; then + return 1 + fi + if is_explicit_package_install_spec "$value"; then + return 1 + fi + return 0 +} + +resolve_package_install_spec() { + local package_name="$1" + local value="$2" + if [[ "${value,,}" == "main" ]]; then + echo "github:openclaw/openclaw#main" + return 0 + fi + if is_explicit_package_install_spec "$value"; then + echo "$value" + return 0 + fi + if [[ "$value" == "latest" ]]; then + echo "${package_name}@latest" + return 0 + fi + echo "${package_name}@${value}" +} + install_openclaw() { local package_name="openclaw" if [[ "$USE_BETA" == "1" ]]; then @@ -1983,18 +2021,16 @@ install_openclaw() { fi local resolved_version="" - resolved_version="$(npm view "${package_name}@${OPENCLAW_VERSION}" version 2>/dev/null || true)" + if can_resolve_registry_package_version "${OPENCLAW_VERSION}"; then + resolved_version="$(npm view "${package_name}@${OPENCLAW_VERSION}" version 2>/dev/null || true)" + fi if [[ -n "$resolved_version" ]]; then ui_info "Installing OpenClaw v${resolved_version}" else ui_info "Installing OpenClaw (${OPENCLAW_VERSION})" fi local install_spec="" - if [[ "${OPENCLAW_VERSION}" == "latest" ]]; then - install_spec="${package_name}@latest" - else - install_spec="${package_name}@${OPENCLAW_VERSION}" - fi + install_spec="$(resolve_package_install_spec "${package_name}" "${OPENCLAW_VERSION}")" if ! install_openclaw_npm "${install_spec}"; then ui_warn "npm install failed; retrying" diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index f2138215327..77593f876aa 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -549,6 +549,48 @@ describe("update-cli", () => { ); }); + it("maps --tag main to the GitHub main package spec for package updates", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + + await updateCommand({ yes: true, tag: "main" }); + + expect(runGatewayUpdate).not.toHaveBeenCalled(); + expect(runCommandWithTimeout).toHaveBeenCalledWith( + [ + "npm", + "i", + "-g", + "github:openclaw/openclaw#main", + "--no-fund", + "--no-audit", + "--loglevel=error", + ], + expect.any(Object), + ); + }); + + it("passes explicit git package specs through for package updates", async () => { + const tempDir = createCaseDir("openclaw-update"); + mockPackageInstallStatus(tempDir); + + await updateCommand({ yes: true, tag: "github:openclaw/openclaw#main" }); + + expect(runGatewayUpdate).not.toHaveBeenCalled(); + expect(runCommandWithTimeout).toHaveBeenCalledWith( + [ + "npm", + "i", + "-g", + "github:openclaw/openclaw#main", + "--no-fund", + "--no-audit", + "--loglevel=error", + ], + expect.any(Object), + ); + }); + it("updateCommand outputs JSON when --json is set", async () => { vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); vi.mocked(defaultRuntime.log).mockClear(); diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 7f82f701c8a..529b65cd917 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -39,7 +39,10 @@ export function registerUpdateCli(program: Command) { .option("--no-restart", "Skip restarting the gateway service after a successful update") .option("--dry-run", "Preview update actions without making changes", false) .option("--channel ", "Persist update channel (git + npm)") - .option("--tag ", "Override npm dist-tag or version for this update") + .option( + "--tag ", + "Override the package target for this update (dist-tag, version, or package spec)", + ) .option("--timeout ", "Timeout for each update step in seconds (default: 1200)") .option("--yes", "Skip confirmation prompts (non-interactive)", false) .addHelpText("after", () => { @@ -48,6 +51,7 @@ export function registerUpdateCli(program: Command) { ["openclaw update --channel beta", "Switch to beta channel (git + npm)"], ["openclaw update --channel dev", "Switch to dev channel (git + npm)"], ["openclaw update --tag beta", "One-off update to a dist-tag or version"], + ["openclaw update --tag main", "One-off package install from GitHub main"], ["openclaw update --dry-run", "Preview actions without changing anything"], ["openclaw update --no-restart", "Update without restarting the service"], ["openclaw update --json", "Output result as JSON"], @@ -66,7 +70,7 @@ ${theme.heading("What this does:")} ${theme.heading("Switch channels:")} - Use --channel stable|beta|dev to persist the update channel in config - Run openclaw update status to see the active channel and source - - Use --tag for a one-off npm update without persisting + - Use --tag for a one-off package update without persisting ${theme.heading("Non-interactive:")} - Use --yes to accept downgrade prompts diff --git a/src/cli/update-cli/shared.ts b/src/cli/update-cli/shared.ts index d7cbc5ec86b..1f934f3c9be 100644 --- a/src/cli/update-cli/shared.ts +++ b/src/cli/update-cli/shared.ts @@ -10,6 +10,7 @@ import { trimLogTail } from "../../infra/restart-sentinel.js"; import { parseSemver } from "../../infra/runtime-guard.js"; import { fetchNpmTagVersion } from "../../infra/update-check.js"; import { + canResolveRegistryVersionForPackageTarget, detectGlobalInstallManagerByPresence, detectGlobalInstallManagerForRoot, type CommandRunner, @@ -77,6 +78,9 @@ export async function resolveTargetVersion( tag: string, timeoutMs?: number, ): Promise { + if (!canResolveRegistryVersionForPackageTarget(tag)) { + return null; + } const direct = normalizeVersionTag(tag); if (direct) { return direct; diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index b94fbd4ffb9..abc9c0080c7 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -24,6 +24,7 @@ import { checkUpdateStatus, } from "../../infra/update-check.js"; import { + canResolveRegistryVersionForPackageTarget, createGlobalInstallEnv, cleanupGlobalRenameDirs, globalInstallArgs, @@ -731,22 +732,31 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { let targetVersion: string | null = null; let downgradeRisk = false; let fallbackToLatest = false; + let packageInstallSpec: string | null = null; if (updateInstallKind !== "git") { currentVersion = switchToPackage ? null : await readPackageVersion(root); - targetVersion = explicitTag - ? await resolveTargetVersion(tag, timeoutMs) - : await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => { - tag = resolved.tag; - fallbackToLatest = channel === "beta" && resolved.tag === "latest"; - return resolved.version; - }); + if (explicitTag) { + targetVersion = await resolveTargetVersion(tag, timeoutMs); + } else { + targetVersion = await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => { + tag = resolved.tag; + fallbackToLatest = channel === "beta" && resolved.tag === "latest"; + return resolved.version; + }); + } const cmp = currentVersion && targetVersion ? compareSemverStrings(currentVersion, targetVersion) : null; downgradeRisk = + canResolveRegistryVersionForPackageTarget(tag) && !fallbackToLatest && currentVersion != null && (targetVersion == null || (cmp != null && cmp > 0)); + packageInstallSpec = resolveGlobalInstallSpec({ + packageName: DEFAULT_PACKAGE_NAME, + tag, + env: process.env, + }); } if (opts.dryRun) { @@ -772,7 +782,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { } else if (updateInstallKind === "git") { actions.push(`Run git update flow on channel ${channel} (fetch/rebase/build/doctor)`); } else { - actions.push(`Run global package manager update with spec openclaw@${tag}`); + actions.push(`Run global package manager update with spec ${packageInstallSpec ?? tag}`); } actions.push("Run plugin update sync after core update"); actions.push("Refresh shell completion cache (if needed)"); @@ -789,6 +799,9 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { if (fallbackToLatest) { notes.push("Beta channel resolves to latest for this run (fallback)."); } + if (explicitTag && !canResolveRegistryVersionForPackageTarget(tag)) { + notes.push("Non-registry package specs skip npm version lookup and downgrade previews."); + } printDryRunPreview( { @@ -803,7 +816,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { requestedChannel, storedChannel, effectiveChannel: channel, - tag, + tag: packageInstallSpec ?? tag, currentVersion, targetVersion, downgradeRisk, diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index 54cda49a407..3df6151e11c 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -4,11 +4,15 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { captureEnv } from "../test-utils/env.js"; import { + canResolveRegistryVersionForPackageTarget, cleanupGlobalRenameDirs, detectGlobalInstallManagerByPresence, detectGlobalInstallManagerForRoot, globalInstallArgs, globalInstallFallbackArgs, + isExplicitPackageInstallSpec, + isMainPackageTarget, + OPENCLAW_MAIN_PACKAGE_SPEC, resolveGlobalPackageRoot, resolveGlobalInstallSpec, resolveGlobalRoot, @@ -60,6 +64,40 @@ describe("update global helpers", () => { ); }); + it("maps main and explicit install specs for global installs", () => { + expect(resolveGlobalInstallSpec({ packageName: "openclaw", tag: "main" })).toBe( + OPENCLAW_MAIN_PACKAGE_SPEC, + ); + expect( + resolveGlobalInstallSpec({ + packageName: "openclaw", + tag: "github:openclaw/openclaw#feature/my-branch", + }), + ).toBe("github:openclaw/openclaw#feature/my-branch"); + expect( + resolveGlobalInstallSpec({ + packageName: "openclaw", + tag: "https://example.com/openclaw-main.tgz", + }), + ).toBe("https://example.com/openclaw-main.tgz"); + }); + + it("classifies main and raw install specs separately from registry selectors", () => { + expect(isMainPackageTarget("main")).toBe(true); + expect(isMainPackageTarget(" MAIN ")).toBe(true); + expect(isMainPackageTarget("beta")).toBe(false); + + expect(isExplicitPackageInstallSpec("github:openclaw/openclaw#main")).toBe(true); + expect(isExplicitPackageInstallSpec("https://example.com/openclaw-main.tgz")).toBe(true); + expect(isExplicitPackageInstallSpec("file:/tmp/openclaw-main.tgz")).toBe(true); + expect(isExplicitPackageInstallSpec("beta")).toBe(false); + + expect(canResolveRegistryVersionForPackageTarget("latest")).toBe(true); + expect(canResolveRegistryVersionForPackageTarget("2026.3.14")).toBe(true); + expect(canResolveRegistryVersionForPackageTarget("main")).toBe(false); + expect(canResolveRegistryVersionForPackageTarget("github:openclaw/openclaw#main")).toBe(false); + }); + it("detects install managers from resolved roots and on-disk presence", async () => { const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-global-")); const npmRoot = path.join(base, "npm-root"); diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index 4df88cc2221..e0dc9045f67 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -14,12 +14,41 @@ export type CommandRunner = ( const PRIMARY_PACKAGE_NAME = "openclaw"; const ALL_PACKAGE_NAMES = [PRIMARY_PACKAGE_NAME] as const; const GLOBAL_RENAME_PREFIX = "."; +export const OPENCLAW_MAIN_PACKAGE_SPEC = "github:openclaw/openclaw#main"; const NPM_GLOBAL_INSTALL_QUIET_FLAGS = ["--no-fund", "--no-audit", "--loglevel=error"] as const; const NPM_GLOBAL_INSTALL_OMIT_OPTIONAL_FLAGS = [ "--omit=optional", ...NPM_GLOBAL_INSTALL_QUIET_FLAGS, ] as const; +function normalizePackageTarget(value: string): string { + return value.trim(); +} + +export function isMainPackageTarget(value: string): boolean { + return normalizePackageTarget(value).toLowerCase() === "main"; +} + +export function isExplicitPackageInstallSpec(value: string): boolean { + const trimmed = normalizePackageTarget(value); + if (!trimmed) { + return false; + } + return ( + trimmed.includes("://") || + trimmed.includes("#") || + /^(?:file|github|git\+ssh|git\+https|git\+http|git\+file|npm):/i.test(trimmed) + ); +} + +export function canResolveRegistryVersionForPackageTarget(value: string): boolean { + const trimmed = normalizePackageTarget(value); + if (!trimmed) { + return true; + } + return !isMainPackageTarget(trimmed) && !isExplicitPackageInstallSpec(trimmed); +} + async function resolvePortableGitPathPrepend( env: NodeJS.ProcessEnv | undefined, ): Promise { @@ -68,7 +97,14 @@ export function resolveGlobalInstallSpec(params: { if (override) { return override; } - return `${params.packageName}@${params.tag}`; + const target = normalizePackageTarget(params.tag); + if (isMainPackageTarget(target)) { + return OPENCLAW_MAIN_PACKAGE_SPEC; + } + if (isExplicitPackageInstallSpec(target)) { + return target; + } + return `${params.packageName}@${target}`; } export async function createGlobalInstallEnv( diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index bb9be0d5be7..35716f84c2f 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -441,6 +441,20 @@ describe("runGatewayUpdate", () => { expect(calls.some((call) => call === expectedInstallCommand)).toBe(true); }); + it("updates global npm installs from the GitHub main package spec", async () => { + const { calls, result } = await runNpmGlobalUpdateCase({ + expectedInstallCommand: + "npm i -g github:openclaw/openclaw#main --no-fund --no-audit --loglevel=error", + tag: "main", + }); + + expect(result.status).toBe("ok"); + expect(result.mode).toBe("npm"); + expect(calls).toContain( + "npm i -g github:openclaw/openclaw#main --no-fund --no-audit --loglevel=error", + ); + }); + it("falls back to global npm update when git is missing from PATH", async () => { const { nodeModules, pkgRoot } = await createGlobalPackageFixture(tempDir); const { calls, runCommand } = createGlobalInstallHarness({ From 50c89342318db40c4295193a0877442e7adfe125 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Sun, 15 Mar 2026 23:28:57 +0200 Subject: [PATCH 025/943] fix(dev): align gateway watch with tsdown wrapper (#47636) --- scripts/run-node.mjs | 10 ++++------ scripts/tsdown-build.mjs | 3 ++- src/infra/run-node.test.ts | 39 +++++++++++++++++++++++++------------- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index 56a63805e70..33317ae8797 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -6,8 +6,8 @@ import process from "node:process"; import { pathToFileURL } from "node:url"; import { runRuntimePostBuild } from "./runtime-postbuild.mjs"; -const compiler = "tsdown"; -const compilerArgs = ["exec", compiler, "--no-clean"]; +const buildScript = "scripts/tsdown-build.mjs"; +const compilerArgs = [buildScript, "--no-clean"]; const runNodeSourceRoots = ["src", "extensions"]; const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.config.ts"]; @@ -313,7 +313,6 @@ export async function runNodeMain(params = {}) { cwd: params.cwd ?? process.cwd(), args: params.args ?? process.argv.slice(2), env: params.env ? { ...params.env } : { ...process.env }, - platform: params.platform ?? process.platform, }; deps.distRoot = path.join(deps.cwd, "dist"); @@ -333,9 +332,8 @@ export async function runNodeMain(params = {}) { } logRunner("Building TypeScript (dist is stale).", deps); - const buildCmd = deps.platform === "win32" ? "cmd.exe" : "pnpm"; - const buildArgs = - deps.platform === "win32" ? ["/d", "/s", "/c", "pnpm", ...compilerArgs] : compilerArgs; + const buildCmd = deps.execPath; + const buildArgs = compilerArgs; const build = deps.spawn(buildCmd, buildArgs, { cwd: deps.cwd, env: deps.env, diff --git a/scripts/tsdown-build.mjs b/scripts/tsdown-build.mjs index ccd56a4aff0..1c346b54a78 100644 --- a/scripts/tsdown-build.mjs +++ b/scripts/tsdown-build.mjs @@ -3,9 +3,10 @@ import { spawnSync } from "node:child_process"; const logLevel = process.env.OPENCLAW_BUILD_VERBOSE ? "info" : "warn"; +const extraArgs = process.argv.slice(2); const result = spawnSync( "pnpm", - ["exec", "tsdown", "--config-loader", "unrun", "--logLevel", logLevel], + ["exec", "tsdown", "--config-loader", "unrun", "--logLevel", logLevel, ...extraArgs], { stdio: "inherit", shell: process.platform === "win32", diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 59ac7cd0666..dfebf6c2ad2 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -33,10 +33,8 @@ async function writeRuntimePostBuildScaffold(tmp: string): Promise { await fs.utimes(pluginSdkAliasPath, baselineTime, baselineTime); } -function expectedBuildSpawn(platform: NodeJS.Platform = process.platform) { - return platform === "win32" - ? ["cmd.exe", "/d", "/s", "/c", "pnpm", "exec", "tsdown", "--no-clean"] - : ["pnpm", "exec", "tsdown", "--no-clean"]; +function expectedBuildSpawn() { + return [process.execPath, "scripts/tsdown-build.mjs", "--no-clean"]; } describe("run-node script", () => { @@ -44,7 +42,7 @@ describe("run-node script", () => { "preserves control-ui assets by building with tsdown --no-clean", async () => { await withTempDir(async (tmp) => { - const argsPath = path.join(tmp, ".pnpm-args.txt"); + const argsPath = path.join(tmp, ".build-args.txt"); const indexPath = path.join(tmp, "dist", "control-ui", "index.html"); await writeRuntimePostBuildScaffold(tmp); @@ -53,7 +51,7 @@ describe("run-node script", () => { const nodeCalls: string[][] = []; const spawn = (cmd: string, args: string[]) => { - if (cmd === "pnpm") { + if (cmd === process.execPath && args[0] === "scripts/tsdown-build.mjs") { fsSync.writeFileSync(argsPath, args.join(" "), "utf-8"); if (!args.includes("--no-clean")) { fsSync.rmSync(path.join(tmp, "dist", "control-ui"), { recursive: true, force: true }); @@ -87,9 +85,14 @@ describe("run-node script", () => { }); expect(exitCode).toBe(0); - await expect(fs.readFile(argsPath, "utf-8")).resolves.toContain("exec tsdown --no-clean"); + await expect(fs.readFile(argsPath, "utf-8")).resolves.toContain( + "scripts/tsdown-build.mjs --no-clean", + ); await expect(fs.readFile(indexPath, "utf-8")).resolves.toContain("sentinel"); - expect(nodeCalls).toEqual([[process.execPath, "openclaw.mjs", "--version"]]); + expect(nodeCalls).toEqual([ + [process.execPath, "scripts/tsdown-build.mjs", "--no-clean"], + [process.execPath, "openclaw.mjs", "--version"], + ]); }); }, ); @@ -151,8 +154,10 @@ describe("run-node script", () => { fs.readFile(path.join(tmp, "dist", "plugin-sdk", "root-alias.cjs"), "utf-8"), ).resolves.toContain("module.exports = {};"); await expect( - fs.readFile(path.join(tmp, "dist", "extensions", "demo", "openclaw.plugin.json"), "utf-8"), - ).resolves.toContain('"id":"demo"'); + fs + .readFile(path.join(tmp, "dist", "extensions", "demo", "openclaw.plugin.json"), "utf-8") + .then((raw) => JSON.parse(raw)), + ).resolves.toMatchObject({ id: "demo" }); await expect( fs.readFile(path.join(tmp, "dist", "extensions", "demo", "package.json"), "utf-8"), ).resolves.toContain( @@ -222,7 +227,7 @@ describe("run-node script", () => { it("returns the build exit code when the compiler step fails", async () => { await withTempDir(async (tmp) => { const spawn = (cmd: string, args: string[] = []) => { - if (cmd === "pnpm" || (cmd === "cmd.exe" && args.includes("pnpm"))) { + if (cmd === process.execPath && args[0] === "scripts/tsdown-build.mjs") { return createExitedProcess(23); } return createExitedProcess(0); @@ -501,7 +506,11 @@ describe("run-node script", () => { expect(exitCode).toBe(0); expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); - await expect(fs.readFile(distManifestPath, "utf-8")).resolves.toContain('"id":"demo"'); + await expect( + fs.readFile(distManifestPath, "utf-8").then((raw) => JSON.parse(raw)), + ).resolves.toMatchObject({ + id: "demo", + }); }); }); @@ -567,7 +576,11 @@ describe("run-node script", () => { expect(exitCode).toBe(0); expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); - await expect(fs.readFile(distManifestPath, "utf-8")).resolves.toContain('"id":"demo"'); + await expect( + fs.readFile(distManifestPath, "utf-8").then((raw) => JSON.parse(raw)), + ).resolves.toMatchObject({ + id: "demo", + }); }); }); From b810e94a1756d96bad2fe619fbd4d2e4db359128 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 14:37:41 -0700 Subject: [PATCH 026/943] Commands: lazy-load non-interactive plugin provider runtime (#47593) * Commands: lazy-load non-interactive plugin provider runtime * Tests: cover non-interactive plugin provider ordering * Update src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../auth-choice.plugin-providers.runtime.ts | 4 ++ .../auth-choice.plugin-providers.test.ts | 54 +++++++++++++++++++ .../local/auth-choice.plugin-providers.ts | 12 +++-- .../local/auth-choice.test.ts | 53 ++++++++++++++++++ .../local/auth-choice.ts | 36 ++++++------- 5 files changed, 136 insertions(+), 23 deletions(-) create mode 100644 src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts create mode 100644 src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts create mode 100644 src/commands/onboard-non-interactive/local/auth-choice.test.ts diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts new file mode 100644 index 00000000000..fd4a36d4a9f --- /dev/null +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts @@ -0,0 +1,4 @@ +export { + resolveProviderPluginChoice, +} from "../../../plugins/provider-wizard.js"; +export { resolvePluginProviders } from "../../../plugins/providers.js"; diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts new file mode 100644 index 00000000000..4e0f37e2882 --- /dev/null +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../config/config.js"; +import { applyNonInteractivePluginProviderChoice } from "./auth-choice.plugin-providers.js"; + +const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => undefined)); +vi.mock("../../auth-choice.preferred-provider.js", () => ({ + resolvePreferredProviderForAuthChoice, +})); + +const resolveProviderPluginChoice = vi.hoisted(() => vi.fn()); +const resolvePluginProviders = vi.hoisted(() => vi.fn(() => [])); +vi.mock("./auth-choice.plugin-providers.runtime.js", () => ({ + resolveProviderPluginChoice, + resolvePluginProviders, + PROVIDER_PLUGIN_CHOICE_PREFIX: "provider-plugin:", +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function createRuntime() { + return { + error: vi.fn(), + exit: vi.fn(), + }; +} + +describe("applyNonInteractivePluginProviderChoice", () => { + it("loads plugin providers for provider-plugin auth choices", async () => { + const runtime = createRuntime(); + const runNonInteractive = vi.fn(async () => ({ plugins: { allow: ["vllm"] } })); + resolvePluginProviders.mockReturnValue([{ id: "vllm", pluginId: "vllm" }] as never); + resolveProviderPluginChoice.mockReturnValue({ + provider: { id: "vllm", pluginId: "vllm", label: "vLLM" }, + method: { runNonInteractive }, + }); + + const result = await applyNonInteractivePluginProviderChoice({ + nextConfig: { agents: { defaults: {} } } as OpenClawConfig, + authChoice: "provider-plugin:vllm:custom", + opts: {} as never, + runtime: runtime as never, + baseConfig: { agents: { defaults: {} } } as OpenClawConfig, + resolveApiKey: vi.fn(), + toApiKeyCredential: vi.fn(), + }); + + expect(resolvePluginProviders).toHaveBeenCalledOnce(); + expect(resolveProviderPluginChoice).toHaveBeenCalledOnce(); + expect(runNonInteractive).toHaveBeenCalledOnce(); + expect(result).toEqual({ plugins: { allow: ["vllm"] } }); + }); +}); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts index d6e1440eb20..e5c8dedb12f 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts @@ -3,11 +3,6 @@ import type { ApiKeyCredential } from "../../../agents/auth-profiles/types.js"; import { resolveDefaultAgentWorkspaceDir } from "../../../agents/workspace.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { enablePluginInConfig } from "../../../plugins/enable.js"; -import { - PROVIDER_PLUGIN_CHOICE_PREFIX, - resolveProviderPluginChoice, -} from "../../../plugins/provider-wizard.js"; -import { resolvePluginProviders } from "../../../plugins/providers.js"; import type { ProviderNonInteractiveApiKeyCredentialParams, ProviderResolveNonInteractiveApiKeyParams, @@ -16,6 +11,12 @@ import type { RuntimeEnv } from "../../../runtime.js"; import { resolvePreferredProviderForAuthChoice } from "../../auth-choice.preferred-provider.js"; import type { OnboardOptions } from "../../onboard-types.js"; +const PROVIDER_PLUGIN_CHOICE_PREFIX = "provider-plugin:"; + +async function loadPluginProviderRuntime() { + return import("./auth-choice.plugin-providers.runtime.js"); +} + function buildIsolatedProviderResolutionConfig( cfg: OpenClawConfig, providerId: string | undefined, @@ -73,6 +74,7 @@ export async function applyNonInteractivePluginProviderChoice(params: { params.nextConfig, preferredProviderId, ); + const { resolveProviderPluginChoice, resolvePluginProviders } = await loadPluginProviderRuntime(); const providerChoice = resolveProviderPluginChoice({ providers: resolvePluginProviders({ config: resolutionConfig, diff --git a/src/commands/onboard-non-interactive/local/auth-choice.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.test.ts new file mode 100644 index 00000000000..9fe7a34cda9 --- /dev/null +++ b/src/commands/onboard-non-interactive/local/auth-choice.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../config/config.js"; +import { applyNonInteractiveAuthChoice } from "./auth-choice.js"; + +const applySimpleNonInteractiveApiKeyChoice = vi.hoisted(() => + vi.fn<() => Promise>(async () => undefined), +); +vi.mock("./auth-choice.api-key-providers.js", () => ({ + applySimpleNonInteractiveApiKeyChoice, +})); + +const applyNonInteractivePluginProviderChoice = vi.hoisted(() => vi.fn(async () => undefined)); +vi.mock("./auth-choice.plugin-providers.js", () => ({ + applyNonInteractivePluginProviderChoice, +})); + +const resolveNonInteractiveApiKey = vi.hoisted(() => vi.fn()); +vi.mock("../api-keys.js", () => ({ + resolveNonInteractiveApiKey, +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function createRuntime() { + return { + error: vi.fn(), + exit: vi.fn(), + log: vi.fn(), + }; +} + +describe("applyNonInteractiveAuthChoice", () => { + it("resolves builtin API key auth before plugin provider resolution", async () => { + const runtime = createRuntime(); + const nextConfig = { agents: { defaults: {} } } as OpenClawConfig; + const resolvedConfig = { auth: { profiles: { "openai:default": { mode: "api_key" } } } }; + applySimpleNonInteractiveApiKeyChoice.mockResolvedValueOnce(resolvedConfig as never); + + const result = await applyNonInteractiveAuthChoice({ + nextConfig, + authChoice: "openai-api-key", + opts: {} as never, + runtime: runtime as never, + baseConfig: nextConfig, + }); + + expect(result).toBe(resolvedConfig); + expect(applySimpleNonInteractiveApiKeyChoice).toHaveBeenCalledOnce(); + expect(applyNonInteractivePluginProviderChoice).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 500e19ee574..6d360487ee9 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -161,24 +161,6 @@ export async function applyNonInteractiveAuthChoice(params: { return null; } - const pluginProviderChoice = await applyNonInteractivePluginProviderChoice({ - nextConfig, - authChoice, - opts, - runtime, - baseConfig, - resolveApiKey: (input) => - resolveApiKey({ - ...input, - cfg: baseConfig, - runtime, - }), - toApiKeyCredential, - }); - if (pluginProviderChoice !== undefined) { - return pluginProviderChoice; - } - if (authChoice === "token") { const providerRaw = opts.tokenProvider?.trim(); if (!providerRaw) { @@ -484,6 +466,24 @@ export async function applyNonInteractiveAuthChoice(params: { } } + const pluginProviderChoice = await applyNonInteractivePluginProviderChoice({ + nextConfig, + authChoice, + opts, + runtime, + baseConfig, + resolveApiKey: (input) => + resolveApiKey({ + ...input, + cfg: baseConfig, + runtime, + }), + toApiKeyCredential, + }); + if (pluginProviderChoice !== undefined) { + return pluginProviderChoice; + } + if ( authChoice === "oauth" || authChoice === "chutes" || From 1839bc0b1a45d8fb71dbfbc9f9b65ed164b4d5f4 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 21:41:41 +0000 Subject: [PATCH 027/943] Plugins: relocate bundled skill assets --- scripts/copy-bundled-plugin-metadata.mjs | 44 ++++++++++++-- scripts/runtime-postbuild-shared.mjs | 9 +++ .../copy-bundled-plugin-metadata.test.ts | 57 +++++++++++++++++-- 3 files changed, 100 insertions(+), 10 deletions(-) diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs index af8612a3465..6e121262967 100644 --- a/scripts/copy-bundled-plugin-metadata.mjs +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -1,7 +1,13 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { removeFileIfExists, writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs"; +import { + removeFileIfExists, + removePathIfExists, + writeTextFileIfChanged, +} from "./runtime-postbuild-shared.mjs"; + +const GENERATED_BUNDLED_SKILLS_DIR = "bundled-skills"; export function rewritePackageExtensions(entries) { if (!Array.isArray(entries)) { @@ -30,6 +36,31 @@ function ensurePathInsideRoot(rootDir, rawPath) { throw new Error(`path escapes plugin root: ${rawPath}`); } +function normalizeManifestRelativePath(rawPath) { + return rawPath.replaceAll("\\", "/").replace(/^\.\//u, ""); +} + +function resolveBundledSkillTarget(rawPath) { + const normalized = normalizeManifestRelativePath(rawPath); + if (/^node_modules(?:\/|$)/u.test(normalized)) { + // Bundled dist/plugin roots must not publish nested node_modules trees. Relocate + // dependency-backed skill assets into a dist-owned directory and rewrite the manifest. + const trimmed = normalized.replace(/^node_modules\/?/u, ""); + if (!trimmed) { + throw new Error(`node_modules skill path must point to a package: ${rawPath}`); + } + const bundledRelativePath = `${GENERATED_BUNDLED_SKILLS_DIR}/${trimmed}`; + return { + manifestPath: `./${bundledRelativePath}`, + outputPath: bundledRelativePath, + }; + } + return { + manifestPath: rawPath, + outputPath: normalized, + }; +} + function copyDeclaredPluginSkillPaths(params) { const skills = Array.isArray(params.manifest.skills) ? params.manifest.skills : []; const copiedSkills = []; @@ -37,8 +68,8 @@ function copyDeclaredPluginSkillPaths(params) { if (typeof raw !== "string" || raw.trim().length === 0) { continue; } - const normalized = raw.replace(/^\.\//u, ""); const sourcePath = ensurePathInsideRoot(params.pluginDir, raw); + const target = resolveBundledSkillTarget(raw); if (!fs.existsSync(sourcePath)) { // Some Docker/lightweight builds intentionally omit optional plugin-local // dependencies. Only advertise skill paths that were actually bundled. @@ -47,14 +78,15 @@ function copyDeclaredPluginSkillPaths(params) { ); continue; } - const targetPath = ensurePathInsideRoot(params.distPluginDir, normalized); + const targetPath = ensurePathInsideRoot(params.distPluginDir, target.outputPath); + removePathIfExists(targetPath); fs.mkdirSync(path.dirname(targetPath), { recursive: true }); fs.cpSync(sourcePath, targetPath, { dereference: true, force: true, recursive: true, }); - copiedSkills.push(raw); + copiedSkills.push(target.manifestPath); } return copiedSkills; } @@ -87,6 +119,10 @@ export function copyBundledPluginMetadata(params = {}) { } const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + // Generated skill assets live under a dedicated dist-owned directory. Also + // remove the older bad node_modules tree so release packs cannot pick it up. + removePathIfExists(path.join(distPluginDir, GENERATED_BUNDLED_SKILLS_DIR)); + removePathIfExists(path.join(distPluginDir, "node_modules")); const copiedSkills = copyDeclaredPluginSkillPaths({ manifest, pluginDir, distPluginDir }); const bundledManifest = Array.isArray(manifest.skills) ? { ...manifest, skills: copiedSkills } diff --git a/scripts/runtime-postbuild-shared.mjs b/scripts/runtime-postbuild-shared.mjs index 34ca6bb7930..7d60be6f746 100644 --- a/scripts/runtime-postbuild-shared.mjs +++ b/scripts/runtime-postbuild-shared.mjs @@ -24,3 +24,12 @@ export function removeFileIfExists(filePath) { return false; } } + +export function removePathIfExists(filePath) { + try { + fs.rmSync(filePath, { recursive: true, force: true }); + return true; + } catch { + return false; + } +} diff --git a/src/plugins/copy-bundled-plugin-metadata.test.ts b/src/plugins/copy-bundled-plugin-metadata.test.ts index 46036dc45d9..381671b57f4 100644 --- a/src/plugins/copy-bundled-plugin-metadata.test.ts +++ b/src/plugins/copy-bundled-plugin-metadata.test.ts @@ -66,13 +66,20 @@ describe("copyBundledPluginMetadata", () => { "utf8", ), ).toContain("ACP Router"); + const bundledManifest = JSON.parse( + fs.readFileSync( + path.join(repoRoot, "dist", "extensions", "acpx", "openclaw.plugin.json"), + "utf8", + ), + ) as { skills?: string[] }; + expect(bundledManifest.skills).toEqual(["./skills"]); const packageJson = JSON.parse( fs.readFileSync(path.join(repoRoot, "dist", "extensions", "acpx", "package.json"), "utf8"), ) as { openclaw?: { extensions?: string[] } }; expect(packageJson.openclaw?.extensions).toEqual(["./index.js"]); }); - it("dereferences node_modules-backed skill paths into the bundled dist tree", () => { + it("relocates node_modules-backed skill paths into bundled-skills and rewrites the manifest", () => { const repoRoot = makeRepoRoot("openclaw-bundled-plugin-node-modules-"); const pluginDir = path.join(repoRoot, "extensions", "tlon"); const storeSkillDir = path.join( @@ -101,10 +108,7 @@ describe("copyBundledPluginMetadata", () => { name: "@openclaw/tlon", openclaw: { extensions: ["./index.ts"] }, }); - - copyBundledPluginMetadata({ repoRoot }); - - const copiedSkillDir = path.join( + const staleNodeModulesSkillDir = path.join( repoRoot, "dist", "extensions", @@ -113,11 +117,35 @@ describe("copyBundledPluginMetadata", () => { "@tloncorp", "tlon-skill", ); + fs.mkdirSync(staleNodeModulesSkillDir, { recursive: true }); + fs.writeFileSync(path.join(staleNodeModulesSkillDir, "stale.txt"), "stale\n", "utf8"); + + copyBundledPluginMetadata({ repoRoot }); + + const copiedSkillDir = path.join( + repoRoot, + "dist", + "extensions", + "tlon", + "bundled-skills", + "@tloncorp", + "tlon-skill", + ); expect(fs.existsSync(path.join(copiedSkillDir, "SKILL.md"))).toBe(true); expect(fs.lstatSync(copiedSkillDir).isSymbolicLink()).toBe(false); + expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "tlon", "node_modules"))).toBe( + false, + ); + const bundledManifest = JSON.parse( + fs.readFileSync( + path.join(repoRoot, "dist", "extensions", "tlon", "openclaw.plugin.json"), + "utf8", + ), + ) as { skills?: string[] }; + expect(bundledManifest.skills).toEqual(["./bundled-skills/@tloncorp/tlon-skill"]); }); - it("omits missing declared skill paths from the bundled manifest", () => { + it("omits missing declared skill paths and removes stale generated outputs", () => { const repoRoot = makeRepoRoot("openclaw-bundled-plugin-missing-skill-"); const pluginDir = path.join(repoRoot, "extensions", "tlon"); fs.mkdirSync(pluginDir, { recursive: true }); @@ -130,6 +158,19 @@ describe("copyBundledPluginMetadata", () => { name: "@openclaw/tlon", openclaw: { extensions: ["./index.ts"] }, }); + const staleBundledSkillDir = path.join( + repoRoot, + "dist", + "extensions", + "tlon", + "bundled-skills", + "@tloncorp", + "tlon-skill", + ); + fs.mkdirSync(staleBundledSkillDir, { recursive: true }); + fs.writeFileSync(path.join(staleBundledSkillDir, "SKILL.md"), "# stale\n", "utf8"); + const staleNodeModulesDir = path.join(repoRoot, "dist", "extensions", "tlon", "node_modules"); + fs.mkdirSync(staleNodeModulesDir, { recursive: true }); copyBundledPluginMetadata({ repoRoot }); @@ -140,5 +181,9 @@ describe("copyBundledPluginMetadata", () => { ), ) as { skills?: string[] }; expect(bundledManifest.skills).toEqual([]); + expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "tlon", "bundled-skills"))).toBe( + false, + ); + expect(fs.existsSync(staleNodeModulesDir)).toBe(false); }); }); From 50a6902a9a4b3e686ca32d499a3d049aaf9bbcc4 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 21:43:13 +0000 Subject: [PATCH 028/943] Plugins: skip nested node_modules in bundled skills --- scripts/copy-bundled-plugin-metadata.mjs | 10 ++++++++++ src/plugins/copy-bundled-plugin-metadata.test.ts | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs index 6e121262967..e563e260c6a 100644 --- a/scripts/copy-bundled-plugin-metadata.mjs +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -81,10 +81,20 @@ function copyDeclaredPluginSkillPaths(params) { const targetPath = ensurePathInsideRoot(params.distPluginDir, target.outputPath); removePathIfExists(targetPath); fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + const shouldExcludeNestedNodeModules = /^node_modules(?:\/|$)/u.test( + normalizeManifestRelativePath(raw), + ); fs.cpSync(sourcePath, targetPath, { dereference: true, force: true, recursive: true, + filter: (candidatePath) => { + if (!shouldExcludeNestedNodeModules || candidatePath === sourcePath) { + return true; + } + const relativeCandidate = path.relative(sourcePath, candidatePath).replaceAll("\\", "/"); + return !relativeCandidate.split("/").includes("node_modules"); + }, }); copiedSkills.push(target.manifestPath); } diff --git a/src/plugins/copy-bundled-plugin-metadata.test.ts b/src/plugins/copy-bundled-plugin-metadata.test.ts index 381671b57f4..a02106efef7 100644 --- a/src/plugins/copy-bundled-plugin-metadata.test.ts +++ b/src/plugins/copy-bundled-plugin-metadata.test.ts @@ -93,6 +93,12 @@ describe("copyBundledPluginMetadata", () => { ); fs.mkdirSync(storeSkillDir, { recursive: true }); fs.writeFileSync(path.join(storeSkillDir, "SKILL.md"), "# Tlon Skill\n", "utf8"); + fs.mkdirSync(path.join(storeSkillDir, "node_modules", ".bin"), { recursive: true }); + fs.writeFileSync( + path.join(storeSkillDir, "node_modules", ".bin", "tlon"), + "#!/bin/sh\n", + "utf8", + ); fs.mkdirSync(path.join(pluginDir, "node_modules", "@tloncorp"), { recursive: true }); fs.symlinkSync( storeSkillDir, @@ -133,6 +139,7 @@ describe("copyBundledPluginMetadata", () => { ); expect(fs.existsSync(path.join(copiedSkillDir, "SKILL.md"))).toBe(true); expect(fs.lstatSync(copiedSkillDir).isSymbolicLink()).toBe(false); + expect(fs.existsSync(path.join(copiedSkillDir, "node_modules"))).toBe(false); expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "tlon", "node_modules"))).toBe( false, ); From 14137bef228e25a19fc8f083580a26380859a7e8 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 21:48:09 +0000 Subject: [PATCH 029/943] Plugins: clean stale bundled skill outputs --- scripts/copy-bundled-plugin-metadata.mjs | 12 +++-- .../auth-choice.plugin-providers.runtime.ts | 4 +- .../copy-bundled-plugin-metadata.test.ts | 47 +++++++++++++++++++ 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs index e563e260c6a..2ba04d9cda0 100644 --- a/scripts/copy-bundled-plugin-metadata.mjs +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -110,6 +110,12 @@ export function copyBundledPluginMetadata(params = {}) { } const sourcePluginDirs = new Set(); + const removeGeneratedPluginArtifacts = (distPluginDir) => { + removeFileIfExists(path.join(distPluginDir, "openclaw.plugin.json")); + removeFileIfExists(path.join(distPluginDir, "package.json")); + removePathIfExists(path.join(distPluginDir, GENERATED_BUNDLED_SKILLS_DIR)); + removePathIfExists(path.join(distPluginDir, "node_modules")); + }; for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) { if (!dirent.isDirectory()) { @@ -123,8 +129,7 @@ export function copyBundledPluginMetadata(params = {}) { const distManifestPath = path.join(distPluginDir, "openclaw.plugin.json"); const distPackageJsonPath = path.join(distPluginDir, "package.json"); if (!fs.existsSync(manifestPath)) { - removeFileIfExists(distManifestPath); - removeFileIfExists(distPackageJsonPath); + removeGeneratedPluginArtifacts(distPluginDir); continue; } @@ -165,8 +170,7 @@ export function copyBundledPluginMetadata(params = {}) { continue; } const distPluginDir = path.join(distExtensionsRoot, dirent.name); - removeFileIfExists(path.join(distPluginDir, "openclaw.plugin.json")); - removeFileIfExists(path.join(distPluginDir, "package.json")); + removeGeneratedPluginArtifacts(distPluginDir); } } diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts index fd4a36d4a9f..a19d1861c7e 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts @@ -1,4 +1,2 @@ -export { - resolveProviderPluginChoice, -} from "../../../plugins/provider-wizard.js"; +export { resolveProviderPluginChoice } from "../../../plugins/provider-wizard.js"; export { resolvePluginProviders } from "../../../plugins/providers.js"; diff --git a/src/plugins/copy-bundled-plugin-metadata.test.ts b/src/plugins/copy-bundled-plugin-metadata.test.ts index a02106efef7..9c980381aa8 100644 --- a/src/plugins/copy-bundled-plugin-metadata.test.ts +++ b/src/plugins/copy-bundled-plugin-metadata.test.ts @@ -193,4 +193,51 @@ describe("copyBundledPluginMetadata", () => { ); expect(fs.existsSync(staleNodeModulesDir)).toBe(false); }); + + it("removes generated outputs for plugins no longer present in source", () => { + const repoRoot = makeRepoRoot("openclaw-bundled-plugin-removed-"); + const staleBundledSkillDir = path.join( + repoRoot, + "dist", + "extensions", + "removed-plugin", + "bundled-skills", + "@scope", + "skill", + ); + fs.mkdirSync(staleBundledSkillDir, { recursive: true }); + fs.writeFileSync(path.join(staleBundledSkillDir, "SKILL.md"), "# stale\n", "utf8"); + const staleNodeModulesDir = path.join( + repoRoot, + "dist", + "extensions", + "removed-plugin", + "node_modules", + ); + fs.mkdirSync(staleNodeModulesDir, { recursive: true }); + writeJson(path.join(repoRoot, "dist", "extensions", "removed-plugin", "openclaw.plugin.json"), { + id: "removed-plugin", + configSchema: { type: "object" }, + skills: ["./bundled-skills/@scope/skill"], + }); + writeJson(path.join(repoRoot, "dist", "extensions", "removed-plugin", "package.json"), { + name: "@openclaw/removed-plugin", + }); + fs.mkdirSync(path.join(repoRoot, "extensions"), { recursive: true }); + + copyBundledPluginMetadata({ repoRoot }); + + expect( + fs.existsSync( + path.join(repoRoot, "dist", "extensions", "removed-plugin", "openclaw.plugin.json"), + ), + ).toBe(false); + expect( + fs.existsSync(path.join(repoRoot, "dist", "extensions", "removed-plugin", "package.json")), + ).toBe(false); + expect( + fs.existsSync(path.join(repoRoot, "dist", "extensions", "removed-plugin", "bundled-skills")), + ).toBe(false); + expect(fs.existsSync(staleNodeModulesDir)).toBe(false); + }); }); From 4a0f72866b4dc64228afeef6f1d28d7fa77b2bbf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 15:17:54 -0700 Subject: [PATCH 030/943] feat(plugins): move provider runtimes into bundled plugins --- CHANGELOG.md | 1 + docs/concepts/model-providers.md | 40 +++ docs/tools/plugin.md | 166 ++++++++++++ extensions/github-copilot/index.test.ts | 49 ++++ extensions/github-copilot/index.ts | 137 ++++++++++ extensions/minimax-portal-auth/index.ts | 74 +++-- extensions/openai-codex/index.test.ts | 65 +++++ extensions/openai-codex/index.ts | 189 +++++++++++++ extensions/openrouter/index.ts | 134 +++++++++ extensions/qwen-portal-auth/index.ts | 60 +++-- src/agents/model-compat.test.ts | 27 -- src/agents/model-forward-compat.ts | 84 ------ src/agents/models-config.providers.ts | 80 +----- .../pi-embedded-runner-extraparams.test.ts | 67 +++++ ...pi-agent.auth-profile-rotation.e2e.test.ts | 24 ++ .../pi-embedded-runner/cache-ttl.test.ts | 14 +- src/agents/pi-embedded-runner/cache-ttl.ts | 25 +- src/agents/pi-embedded-runner/compact.ts | 49 +++- src/agents/pi-embedded-runner/extra-params.ts | 119 ++++---- .../model.provider-normalization.ts | 58 +--- src/agents/pi-embedded-runner/model.ts | 133 ++++++--- src/agents/pi-embedded-runner/run.ts | 246 +++++++++++------ src/agents/provider-capabilities.test.ts | 29 +- src/agents/provider-capabilities.ts | 16 +- src/plugin-sdk/core.ts | 11 + src/plugin-sdk/index.ts | 15 +- src/plugin-sdk/minimax-portal-auth.ts | 1 + src/plugin-sdk/qwen-portal-auth.ts | 6 +- src/plugins/config-state.ts | 3 + src/plugins/provider-discovery.test.ts | 50 +++- src/plugins/provider-discovery.ts | 28 +- src/plugins/provider-runtime.test.ts | 186 +++++++++++++ src/plugins/provider-runtime.ts | 123 +++++++++ src/plugins/provider-validation.test.ts | 29 ++ src/plugins/provider-validation.ts | 15 ++ src/plugins/types.ts | 254 +++++++++++++++++- 36 files changed, 2089 insertions(+), 518 deletions(-) create mode 100644 extensions/github-copilot/index.test.ts create mode 100644 extensions/github-copilot/index.ts create mode 100644 extensions/openai-codex/index.test.ts create mode 100644 extensions/openai-codex/index.ts create mode 100644 extensions/openrouter/index.ts create mode 100644 src/plugins/provider-runtime.test.ts create mode 100644 src/plugins/provider-runtime.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bf37c1757e6..6acb2fd82fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280. - Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898. - Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs. +- Plugins/providers: move OpenRouter, GitHub Copilot, and OpenAI Codex provider/runtime logic into bundled plugins, including dynamic model fallback, runtime auth exchange, stream wrappers, capability hints, and cache-TTL policy. ### Fixes diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index cf2b5229cf8..8793e3fe1d6 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -16,6 +16,46 @@ For model selection rules, see [/concepts/models](/concepts/models). - Model refs use `provider/model` (example: `opencode/claude-opus-4-6`). - If you set `agents.defaults.models`, it becomes the allowlist. - CLI helpers: `openclaw onboard`, `openclaw models list`, `openclaw models set `. +- Provider plugins can inject model catalogs via `registerProvider({ catalog })`; + OpenClaw merges that output into `models.providers` before writing + `models.json`. +- Provider plugins can also own provider runtime behavior via + `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, + `capabilities`, `prepareExtraParams`, `wrapStreamFn`, + `isCacheTtlEligible`, and `prepareRuntimeAuth`. + +## Plugin-owned provider behavior + +Provider plugins can now own most provider-specific logic while OpenClaw keeps +the generic inference loop. + +Typical split: + +- `catalog`: provider appears in `models.providers` +- `resolveDynamicModel`: provider accepts model ids not present in the local + static catalog yet +- `prepareDynamicModel`: provider needs a metadata refresh before retrying + dynamic resolution +- `normalizeResolvedModel`: provider needs transport or base URL rewrites +- `capabilities`: provider publishes transcript/tooling/provider-family quirks +- `prepareExtraParams`: provider defaults or normalizes per-model request params +- `wrapStreamFn`: provider applies request headers/body/model compat wrappers +- `isCacheTtlEligible`: provider decides which upstream model ids support prompt-cache TTL +- `prepareRuntimeAuth`: provider turns a configured credential into a short + lived runtime token + +Current bundled examples: + +- `openrouter`: pass-through model ids, request wrappers, provider capability + hints, and cache-TTL policy +- `github-copilot`: forward-compat model fallback, Claude-thinking transcript + hints, and runtime token exchange +- `openai-codex`: forward-compat model fallback, transport normalization, and + default transport params + +That covers providers that still fit OpenClaw's normal transports. A provider +that needs a totally custom request executor is a separate, deeper extension +surface. ## API key rotation diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 5455bb2b38d..dbbd1c03d39 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -105,6 +105,9 @@ Important trust note: - [Microsoft Teams](/channels/msteams) — `@openclaw/msteams` - Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default) - Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default) +- GitHub Copilot provider runtime — bundled as `github-copilot` (enabled by default) +- OpenAI Codex provider runtime — bundled as `openai-codex` (enabled by default) +- OpenRouter provider runtime — bundled as `openrouter` (enabled by default) - Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default) - Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default) @@ -120,6 +123,8 @@ Plugins can register: - CLI commands - Background services - Context engines +- Provider auth flows and model catalogs +- Provider runtime hooks for dynamic model ids, transport normalization, capability metadata, stream wrapping, cache TTL policy, and runtime auth exchange - Optional config validation - **Skills** (by listing `skills` directories in the plugin manifest) - **Auto-reply commands** (execute without invoking the AI agent) @@ -127,6 +132,137 @@ Plugins can register: Plugins run **in‑process** with the Gateway, so treat them as trusted code. Tool authoring guide: [Plugin agent tools](/plugins/agent-tools). +## Provider runtime hooks + +Provider plugins now have two layers: + +- config-time hooks: `catalog` / legacy `discovery` +- runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `isCacheTtlEligible`, `prepareRuntimeAuth` + +OpenClaw still owns the generic agent loop, failover, transcript handling, and +tool policy. These hooks are the seam for provider-specific behavior without +needing a whole custom inference transport. + +### Hook order + +For model/provider plugins, OpenClaw uses hooks in this rough order: + +1. `catalog` + Publish provider config into `models.providers` during `models.json` + generation. +2. built-in/discovered model lookup + OpenClaw tries the normal registry/catalog path first. +3. `resolveDynamicModel` + Sync fallback for provider-owned model ids that are not in the local + registry yet. +4. `prepareDynamicModel` + Async warm-up only on async model resolution paths, then + `resolveDynamicModel` runs again. +5. `normalizeResolvedModel` + Final rewrite before the embedded runner uses the resolved model. +6. `capabilities` + Provider-owned transcript/tooling metadata used by shared core logic. +7. `prepareExtraParams` + Provider-owned request-param normalization before generic stream option wrappers. +8. `wrapStreamFn` + Provider-owned stream wrapper after generic wrappers are applied. +9. `isCacheTtlEligible` + Provider-owned prompt-cache policy for proxy/backhaul providers. +10. `prepareRuntimeAuth` + Exchanges a configured credential into the actual runtime token/key just + before inference. + +### Which hook to use + +- `catalog`: publish provider config and model catalogs into `models.providers` +- `resolveDynamicModel`: handle pass-through or forward-compat model ids that are not in the local registry yet +- `prepareDynamicModel`: async warm-up before retrying dynamic resolution (for example refresh provider metadata cache) +- `normalizeResolvedModel`: rewrite a resolved model's transport/base URL/compat before inference +- `capabilities`: publish provider-family and transcript/tooling quirks without hardcoding provider ids in core +- `prepareExtraParams`: set provider defaults or normalize provider-specific per-model params before generic stream wrapping +- `wrapStreamFn`: add provider-specific headers/payload/model compat patches while still using the normal `pi-ai` execution path +- `isCacheTtlEligible`: decide whether provider/model pairs should use cache TTL metadata +- `prepareRuntimeAuth`: exchange a configured credential into the actual short-lived runtime token/key used for requests + +Rule of thumb: + +- provider owns a catalog or base URL defaults: use `catalog` +- provider accepts arbitrary upstream model ids: use `resolveDynamicModel` +- provider needs network metadata before resolving unknown ids: add `prepareDynamicModel` +- provider needs transport rewrites but still uses a core transport: use `normalizeResolvedModel` +- provider needs transcript/provider-family quirks: use `capabilities` +- provider needs default request params or per-provider param cleanup: use `prepareExtraParams` +- provider needs request headers/body/model compat wrappers without a custom transport: use `wrapStreamFn` +- provider needs proxy-specific cache TTL gating: use `isCacheTtlEligible` +- provider needs a token exchange or short-lived request credential: use `prepareRuntimeAuth` + +If the provider needs a fully custom wire protocol or custom request executor, +that is a different class of extension. These hooks are for provider behavior +that still runs on OpenClaw's normal inference loop. + +### Example + +```ts +api.registerProvider({ + id: "example-proxy", + label: "Example Proxy", + auth: [], + catalog: { + order: "simple", + run: async (ctx) => { + const apiKey = ctx.resolveProviderApiKey("example-proxy").apiKey; + if (!apiKey) { + return null; + } + return { + provider: { + baseUrl: "https://proxy.example.com/v1", + apiKey, + api: "openai-completions", + models: [{ id: "auto", name: "Auto" }], + }, + }; + }, + }, + resolveDynamicModel: (ctx) => ({ + id: ctx.modelId, + name: ctx.modelId, + provider: "example-proxy", + api: "openai-completions", + baseUrl: "https://proxy.example.com/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }), + prepareRuntimeAuth: async (ctx) => { + const exchanged = await exchangeToken(ctx.apiKey); + return { + apiKey: exchanged.token, + baseUrl: exchanged.baseUrl, + expiresAt: exchanged.expiresAt, + }; + }, +}); +``` + +### Built-in examples + +- OpenRouter uses `catalog` plus `resolveDynamicModel` and + `prepareDynamicModel` because the provider is pass-through and may expose new + model ids before OpenClaw's static catalog updates. +- GitHub Copilot uses `catalog`, `resolveDynamicModel`, and + `capabilities` plus `prepareRuntimeAuth` because it needs model fallback + behavior, Claude transcript quirks, and a GitHub token -> Copilot token exchange. +- OpenAI Codex uses `catalog`, `resolveDynamicModel`, and + `normalizeResolvedModel` plus `prepareExtraParams` because it still runs on + core OpenAI transports but owns its transport/base URL normalization and + default transport choice. +- OpenRouter uses `capabilities`, `wrapStreamFn`, and `isCacheTtlEligible` + to keep provider-specific request headers, routing metadata, reasoning + patches, and prompt-cache policy out of core. + ## Load pipeline At startup, OpenClaw does roughly this: @@ -268,6 +404,36 @@ authoring plugins: `openclaw/plugin-sdk/twitch`, `openclaw/plugin-sdk/voice-call`, `openclaw/plugin-sdk/zalo`, and `openclaw/plugin-sdk/zalouser`. +## Provider catalogs + +Provider plugins can define model catalogs for inference with +`registerProvider({ catalog: { run(...) { ... } } })`. + +`catalog.run(...)` returns the same shape OpenClaw writes into +`models.providers`: + +- `{ provider }` for one provider entry +- `{ providers }` for multiple provider entries + +Use `catalog` when the plugin owns provider-specific model ids, base URL +defaults, or auth-gated model metadata. + +`catalog.order` controls when a plugin's catalog merges relative to OpenClaw's +built-in implicit providers: + +- `simple`: plain API-key or env-driven providers +- `profile`: providers that appear when auth profiles exist +- `paired`: providers that synthesize multiple related provider entries +- `late`: last pass, after other implicit providers + +Later providers win on key collision, so plugins can intentionally override a +built-in provider entry with the same provider id. + +Compatibility: + +- `discovery` still works as a legacy alias +- if both `catalog` and `discovery` are registered, OpenClaw uses `catalog` + Compatibility note: - `openclaw/plugin-sdk` remains supported for existing external plugins. diff --git a/extensions/github-copilot/index.test.ts b/extensions/github-copilot/index.test.ts new file mode 100644 index 00000000000..e69fee13b88 --- /dev/null +++ b/extensions/github-copilot/index.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import type { ProviderPlugin } from "../../src/plugins/types.js"; +import githubCopilotPlugin from "./index.js"; + +function registerProvider(): ProviderPlugin { + let provider: ProviderPlugin | undefined; + githubCopilotPlugin.register({ + registerProvider(nextProvider: ProviderPlugin) { + provider = nextProvider; + }, + } as never); + if (!provider) { + throw new Error("provider registration missing"); + } + return provider; +} + +describe("github-copilot plugin", () => { + it("owns Copilot-specific forward-compat fallbacks", () => { + const provider = registerProvider(); + const model = provider.resolveDynamicModel?.({ + provider: "github-copilot", + modelId: "gpt-5.3-codex", + modelRegistry: { + find: (_provider: string, id: string) => + id === "gpt-5.2-codex" + ? { + id, + name: id, + api: "openai-codex-responses", + provider: "github-copilot", + baseUrl: "https://api.copilot.example", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + } + : null, + } as never, + }); + + expect(model).toMatchObject({ + id: "gpt-5.3-codex", + provider: "github-copilot", + api: "openai-codex-responses", + }); + }); +}); diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts new file mode 100644 index 00000000000..d38e7442d75 --- /dev/null +++ b/extensions/github-copilot/index.ts @@ -0,0 +1,137 @@ +import { + emptyPluginConfigSchema, + type OpenClawPluginApi, + type ProviderResolveDynamicModelContext, + type ProviderRuntimeModel, +} from "openclaw/plugin-sdk/core"; +import { listProfilesForProvider } from "../../src/agents/auth-profiles/profiles.js"; +import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js"; +import { normalizeModelCompat } from "../../src/agents/model-compat.js"; +import { coerceSecretRef } from "../../src/config/types.secrets.js"; +import { + DEFAULT_COPILOT_API_BASE_URL, + resolveCopilotApiToken, +} from "../../src/providers/github-copilot-token.js"; + +const PROVIDER_ID = "github-copilot"; +const COPILOT_ENV_VARS = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]; +const CODEX_GPT_53_MODEL_ID = "gpt-5.3-codex"; +const CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const; + +function resolveFirstGithubToken(params: { agentDir?: string; env: NodeJS.ProcessEnv }): { + githubToken: string; + hasProfile: boolean; +} { + const authStore = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const hasProfile = listProfilesForProvider(authStore, PROVIDER_ID).length > 0; + const envToken = + params.env.COPILOT_GITHUB_TOKEN ?? params.env.GH_TOKEN ?? params.env.GITHUB_TOKEN ?? ""; + const githubToken = envToken.trim(); + if (githubToken || !hasProfile) { + return { githubToken, hasProfile }; + } + + const profileId = listProfilesForProvider(authStore, PROVIDER_ID)[0]; + const profile = profileId ? authStore.profiles[profileId] : undefined; + if (profile?.type !== "token") { + return { githubToken: "", hasProfile }; + } + const directToken = profile.token?.trim() ?? ""; + if (directToken) { + return { githubToken: directToken, hasProfile }; + } + const tokenRef = coerceSecretRef(profile.tokenRef); + if (tokenRef?.source === "env" && tokenRef.id.trim()) { + return { + githubToken: (params.env[tokenRef.id] ?? process.env[tokenRef.id] ?? "").trim(), + hasProfile, + }; + } + return { githubToken: "", hasProfile }; +} + +function resolveCopilotForwardCompatModel( + ctx: ProviderResolveDynamicModelContext, +): ProviderRuntimeModel | undefined { + const trimmedModelId = ctx.modelId.trim(); + if (trimmedModelId.toLowerCase() !== CODEX_GPT_53_MODEL_ID) { + return undefined; + } + for (const templateId of CODEX_TEMPLATE_MODEL_IDS) { + const template = ctx.modelRegistry.find(PROVIDER_ID, templateId) as ProviderRuntimeModel | null; + if (!template) { + continue; + } + return normalizeModelCompat({ + ...template, + id: trimmedModelId, + name: trimmedModelId, + } as ProviderRuntimeModel); + } + return undefined; +} + +const githubCopilotPlugin = { + id: "github-copilot", + name: "GitHub Copilot Provider", + description: "Bundled GitHub Copilot provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "GitHub Copilot", + docsPath: "/providers/models", + envVars: COPILOT_ENV_VARS, + auth: [], + catalog: { + order: "late", + run: async (ctx) => { + const { githubToken, hasProfile } = resolveFirstGithubToken({ + agentDir: ctx.agentDir, + env: ctx.env, + }); + if (!hasProfile && !githubToken) { + return null; + } + let baseUrl = DEFAULT_COPILOT_API_BASE_URL; + if (githubToken) { + try { + const token = await resolveCopilotApiToken({ + githubToken, + env: ctx.env, + }); + baseUrl = token.baseUrl; + } catch { + baseUrl = DEFAULT_COPILOT_API_BASE_URL; + } + } + return { + provider: { + baseUrl, + models: [], + }, + }; + }, + }, + resolveDynamicModel: (ctx) => resolveCopilotForwardCompatModel(ctx), + capabilities: { + dropThinkingBlockModelHints: ["claude"], + }, + prepareRuntimeAuth: async (ctx) => { + const token = await resolveCopilotApiToken({ + githubToken: ctx.apiKey, + env: ctx.env, + }); + return { + apiKey: token.token, + baseUrl: token.baseUrl, + expiresAt: token.expiresAt, + }; + }, + }); + }, +}; + +export default githubCopilotPlugin; diff --git a/extensions/minimax-portal-auth/index.ts b/extensions/minimax-portal-auth/index.ts index d2d1bab9899..ac36106a42e 100644 --- a/extensions/minimax-portal-auth/index.ts +++ b/extensions/minimax-portal-auth/index.ts @@ -4,6 +4,7 @@ import { type OpenClawPluginApi, type ProviderAuthContext, type ProviderAuthResult, + type ProviderCatalogContext, } from "openclaw/plugin-sdk/minimax-portal-auth"; import { loginMiniMaxPortalOAuth, type MiniMaxRegion } from "./oauth.js"; @@ -14,7 +15,6 @@ const DEFAULT_BASE_URL_CN = "https://api.minimaxi.com/anthropic"; const DEFAULT_BASE_URL_GLOBAL = "https://api.minimax.io/anthropic"; const DEFAULT_CONTEXT_WINDOW = 200000; const DEFAULT_MAX_TOKENS = 8192; -const OAUTH_PLACEHOLDER = "minimax-oauth"; function getDefaultBaseUrl(region: MiniMaxRegion): string { return region === "cn" ? DEFAULT_BASE_URL_CN : DEFAULT_BASE_URL_GLOBAL; @@ -41,6 +41,53 @@ function buildModelDefinition(params: { }; } +function buildProviderCatalog(params: { baseUrl: string; apiKey: string }) { + return { + baseUrl: params.baseUrl, + apiKey: params.apiKey, + api: "anthropic-messages" as const, + models: [ + buildModelDefinition({ + id: "MiniMax-M2.5", + name: "MiniMax M2.5", + input: ["text"], + }), + buildModelDefinition({ + id: "MiniMax-M2.5-highspeed", + name: "MiniMax M2.5 Highspeed", + input: ["text"], + reasoning: true, + }), + buildModelDefinition({ + id: "MiniMax-M2.5-Lightning", + name: "MiniMax M2.5 Lightning", + input: ["text"], + reasoning: true, + }), + ], + }; +} + +function resolveCatalog(ctx: ProviderCatalogContext) { + const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID]; + const apiKey = + ctx.resolveProviderApiKey(PROVIDER_ID).apiKey ?? + (typeof explicitProvider?.apiKey === "string" ? explicitProvider.apiKey.trim() : undefined); + if (!apiKey) { + return null; + } + + const explicitBaseUrl = + typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : undefined; + + return { + provider: buildProviderCatalog({ + baseUrl: explicitBaseUrl || DEFAULT_BASE_URL_GLOBAL, + apiKey, + }), + }; +} + function createOAuthHandler(region: MiniMaxRegion) { const defaultBaseUrl = getDefaultBaseUrl(region); const regionLabel = region === "cn" ? "CN" : "Global"; @@ -74,27 +121,7 @@ function createOAuthHandler(region: MiniMaxRegion) { providers: { [PROVIDER_ID]: { baseUrl, - apiKey: OAUTH_PLACEHOLDER, - api: "anthropic-messages", - models: [ - buildModelDefinition({ - id: "MiniMax-M2.5", - name: "MiniMax M2.5", - input: ["text"], - }), - buildModelDefinition({ - id: "MiniMax-M2.5-highspeed", - name: "MiniMax M2.5 Highspeed", - input: ["text"], - reasoning: true, - }), - buildModelDefinition({ - id: "MiniMax-M2.5-Lightning", - name: "MiniMax M2.5 Lightning", - input: ["text"], - reasoning: true, - }), - ], + models: [], }, }, }, @@ -141,6 +168,9 @@ const minimaxPortalPlugin = { label: PROVIDER_LABEL, docsPath: "/providers/minimax", aliases: ["minimax"], + catalog: { + run: async (ctx: ProviderCatalogContext) => resolveCatalog(ctx), + }, auth: [ { id: "oauth", diff --git a/extensions/openai-codex/index.test.ts b/extensions/openai-codex/index.test.ts new file mode 100644 index 00000000000..95dd1aa1a73 --- /dev/null +++ b/extensions/openai-codex/index.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import type { ProviderPlugin } from "../../src/plugins/types.js"; +import openAICodexPlugin from "./index.js"; + +function registerProvider(): ProviderPlugin { + let provider: ProviderPlugin | undefined; + openAICodexPlugin.register({ + registerProvider(nextProvider: ProviderPlugin) { + provider = nextProvider; + }, + } as never); + if (!provider) { + throw new Error("provider registration missing"); + } + return provider; +} + +describe("openai-codex plugin", () => { + it("owns forward-compat codex models", () => { + const provider = registerProvider(); + const model = provider.resolveDynamicModel?.({ + provider: "openai-codex", + modelId: "gpt-5.4", + modelRegistry: { + find: (_provider: string, id: string) => + id === "gpt-5.2-codex" + ? { + id, + name: id, + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + } + : null, + } as never, + }); + + expect(model).toMatchObject({ + id: "gpt-5.4", + provider: "openai-codex", + api: "openai-codex-responses", + contextWindow: 1_050_000, + maxTokens: 128_000, + }); + }); + + it("owns codex transport defaults", () => { + const provider = registerProvider(); + expect( + provider.prepareExtraParams?.({ + provider: "openai-codex", + modelId: "gpt-5.4", + extraParams: { temperature: 0.2 }, + }), + ).toEqual({ + temperature: 0.2, + transport: "auto", + }); + }); +}); diff --git a/extensions/openai-codex/index.ts b/extensions/openai-codex/index.ts new file mode 100644 index 00000000000..592223f2419 --- /dev/null +++ b/extensions/openai-codex/index.ts @@ -0,0 +1,189 @@ +import { + emptyPluginConfigSchema, + type OpenClawPluginApi, + type ProviderResolveDynamicModelContext, + type ProviderRuntimeModel, +} from "openclaw/plugin-sdk/core"; +import { listProfilesForProvider } from "../../src/agents/auth-profiles/profiles.js"; +import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js"; +import { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.js"; +import { normalizeModelCompat } from "../../src/agents/model-compat.js"; +import { normalizeProviderId } from "../../src/agents/model-selection.js"; +import { buildOpenAICodexProvider } from "../../src/agents/models-config.providers.static.js"; + +const PROVIDER_ID = "openai-codex"; +const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; +const OPENAI_CODEX_GPT_54_MODEL_ID = "gpt-5.4"; +const OPENAI_CODEX_GPT_54_CONTEXT_TOKENS = 1_050_000; +const OPENAI_CODEX_GPT_54_MAX_TOKENS = 128_000; +const OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.3-codex", "gpt-5.2-codex"] as const; +const OPENAI_CODEX_GPT_53_MODEL_ID = "gpt-5.3-codex"; +const OPENAI_CODEX_GPT_53_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; +const OPENAI_CODEX_GPT_53_SPARK_CONTEXT_TOKENS = 128_000; +const OPENAI_CODEX_GPT_53_SPARK_MAX_TOKENS = 128_000; +const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const; + +function isOpenAIApiBaseUrl(baseUrl?: string): boolean { + const trimmed = baseUrl?.trim(); + if (!trimmed) { + return false; + } + return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/i.test(trimmed); +} + +function isOpenAICodexBaseUrl(baseUrl?: string): boolean { + const trimmed = baseUrl?.trim(); + if (!trimmed) { + return false; + } + return /^https?:\/\/chatgpt\.com\/backend-api\/?$/i.test(trimmed); +} + +function normalizeCodexTransport(model: ProviderRuntimeModel): ProviderRuntimeModel { + const useCodexTransport = + !model.baseUrl || isOpenAIApiBaseUrl(model.baseUrl) || isOpenAICodexBaseUrl(model.baseUrl); + const api = + useCodexTransport && model.api === "openai-responses" ? "openai-codex-responses" : model.api; + const baseUrl = + api === "openai-codex-responses" && (!model.baseUrl || isOpenAIApiBaseUrl(model.baseUrl)) + ? OPENAI_CODEX_BASE_URL + : model.baseUrl; + if (api === model.api && baseUrl === model.baseUrl) { + return model; + } + return { + ...model, + api, + baseUrl, + }; +} + +function cloneFirstTemplateModel(params: { + modelId: string; + templateIds: readonly string[]; + ctx: ProviderResolveDynamicModelContext; + patch?: Partial; +}): ProviderRuntimeModel | undefined { + const trimmedModelId = params.modelId.trim(); + for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { + const template = params.ctx.modelRegistry.find( + PROVIDER_ID, + templateId, + ) as ProviderRuntimeModel | null; + if (!template) { + continue; + } + return normalizeModelCompat({ + ...template, + id: trimmedModelId, + name: trimmedModelId, + ...params.patch, + } as ProviderRuntimeModel); + } + return undefined; +} + +function resolveCodexForwardCompatModel( + ctx: ProviderResolveDynamicModelContext, +): ProviderRuntimeModel | undefined { + const trimmedModelId = ctx.modelId.trim(); + const lower = trimmedModelId.toLowerCase(); + + let templateIds: readonly string[]; + let patch: Partial | undefined; + if (lower === OPENAI_CODEX_GPT_54_MODEL_ID) { + templateIds = OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS; + patch = { + contextWindow: OPENAI_CODEX_GPT_54_CONTEXT_TOKENS, + maxTokens: OPENAI_CODEX_GPT_54_MAX_TOKENS, + }; + } else if (lower === OPENAI_CODEX_GPT_53_SPARK_MODEL_ID) { + templateIds = [OPENAI_CODEX_GPT_53_MODEL_ID, ...OPENAI_CODEX_TEMPLATE_MODEL_IDS]; + patch = { + api: "openai-codex-responses", + provider: PROVIDER_ID, + baseUrl: OPENAI_CODEX_BASE_URL, + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: OPENAI_CODEX_GPT_53_SPARK_CONTEXT_TOKENS, + maxTokens: OPENAI_CODEX_GPT_53_SPARK_MAX_TOKENS, + }; + } else if (lower === OPENAI_CODEX_GPT_53_MODEL_ID) { + templateIds = OPENAI_CODEX_TEMPLATE_MODEL_IDS; + } else { + return undefined; + } + + return ( + cloneFirstTemplateModel({ + modelId: trimmedModelId, + templateIds, + ctx, + patch, + }) ?? + normalizeModelCompat({ + id: trimmedModelId, + name: trimmedModelId, + api: "openai-codex-responses", + provider: PROVIDER_ID, + baseUrl: OPENAI_CODEX_BASE_URL, + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: patch?.contextWindow ?? DEFAULT_CONTEXT_TOKENS, + maxTokens: patch?.maxTokens ?? DEFAULT_CONTEXT_TOKENS, + } as ProviderRuntimeModel) + ); +} + +const openAICodexPlugin = { + id: "openai-codex", + name: "OpenAI Codex Provider", + description: "Bundled OpenAI Codex provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "OpenAI Codex", + docsPath: "/providers/models", + auth: [], + catalog: { + order: "profile", + run: async (ctx) => { + const authStore = ensureAuthProfileStore(ctx.agentDir, { + allowKeychainPrompt: false, + }); + if (listProfilesForProvider(authStore, PROVIDER_ID).length === 0) { + return null; + } + return { + provider: buildOpenAICodexProvider(), + }; + }, + }, + resolveDynamicModel: (ctx) => resolveCodexForwardCompatModel(ctx), + capabilities: { + providerFamily: "openai", + }, + prepareExtraParams: (ctx) => { + const transport = ctx.extraParams?.transport; + if (transport === "auto" || transport === "sse" || transport === "websocket") { + return ctx.extraParams; + } + return { + ...ctx.extraParams, + transport: "auto", + }; + }, + normalizeResolvedModel: (ctx) => { + if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) { + return undefined; + } + return normalizeCodexTransport(ctx.model); + }, + }); + }, +}; + +export default openAICodexPlugin; diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts new file mode 100644 index 00000000000..faa7b338cf1 --- /dev/null +++ b/extensions/openrouter/index.ts @@ -0,0 +1,134 @@ +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import { + emptyPluginConfigSchema, + type OpenClawPluginApi, + type ProviderResolveDynamicModelContext, + type ProviderRuntimeModel, +} from "openclaw/plugin-sdk/core"; +import { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.js"; +import { buildOpenrouterProvider } from "../../src/agents/models-config.providers.static.js"; +import { + getOpenRouterModelCapabilities, + loadOpenRouterModelCapabilities, +} from "../../src/agents/pi-embedded-runner/openrouter-model-capabilities.js"; +import { + createOpenRouterSystemCacheWrapper, + createOpenRouterWrapper, + isProxyReasoningUnsupported, +} from "../../src/agents/pi-embedded-runner/proxy-stream-wrappers.js"; + +const PROVIDER_ID = "openrouter"; +const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; +const OPENROUTER_DEFAULT_MAX_TOKENS = 8192; +const OPENROUTER_CACHE_TTL_MODEL_PREFIXES = [ + "anthropic/", + "moonshot/", + "moonshotai/", + "zai/", +] as const; + +function buildDynamicOpenRouterModel( + ctx: ProviderResolveDynamicModelContext, +): ProviderRuntimeModel { + const capabilities = getOpenRouterModelCapabilities(ctx.modelId); + return { + id: ctx.modelId, + name: capabilities?.name ?? ctx.modelId, + api: "openai-completions", + provider: PROVIDER_ID, + baseUrl: OPENROUTER_BASE_URL, + reasoning: capabilities?.reasoning ?? false, + input: capabilities?.input ?? ["text"], + cost: capabilities?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: capabilities?.contextWindow ?? DEFAULT_CONTEXT_TOKENS, + maxTokens: capabilities?.maxTokens ?? OPENROUTER_DEFAULT_MAX_TOKENS, + }; +} + +function injectOpenRouterRouting( + baseStreamFn: StreamFn | undefined, + providerRouting?: Record, +): StreamFn | undefined { + if (!providerRouting) { + return baseStreamFn; + } + return (model, context, options) => + ( + baseStreamFn ?? + ((nextModel, nextContext, nextOptions) => { + throw new Error( + `OpenRouter routing wrapper requires an underlying streamFn for ${String(nextModel.id)}.`, + ); + }) + )( + { + ...model, + compat: { ...model.compat, openRouterRouting: providerRouting }, + } as typeof model, + context, + options, + ); +} + +function isOpenRouterCacheTtlModel(modelId: string): boolean { + return OPENROUTER_CACHE_TTL_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix)); +} + +const openRouterPlugin = { + id: "openrouter", + name: "OpenRouter Provider", + description: "Bundled OpenRouter provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "OpenRouter", + docsPath: "/providers/models", + envVars: ["OPENROUTER_API_KEY"], + auth: [], + catalog: { + order: "simple", + run: async (ctx) => { + const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + if (!apiKey) { + return null; + } + return { + provider: { + ...buildOpenrouterProvider(), + apiKey, + }, + }; + }, + }, + resolveDynamicModel: (ctx) => buildDynamicOpenRouterModel(ctx), + prepareDynamicModel: async (ctx) => { + await loadOpenRouterModelCapabilities(ctx.modelId); + }, + capabilities: { + openAiCompatTurnValidation: false, + geminiThoughtSignatureSanitization: true, + geminiThoughtSignatureModelHints: ["gemini"], + }, + wrapStreamFn: (ctx) => { + let streamFn = ctx.streamFn; + const providerRouting = + ctx.extraParams?.provider != null && typeof ctx.extraParams.provider === "object" + ? (ctx.extraParams.provider as Record) + : undefined; + if (providerRouting) { + streamFn = injectOpenRouterRouting(streamFn, providerRouting); + } + const skipReasoningInjection = + ctx.modelId === "auto" || isProxyReasoningUnsupported(ctx.modelId); + const openRouterThinkingLevel = skipReasoningInjection ? undefined : ctx.thinkingLevel; + streamFn = createOpenRouterWrapper(streamFn, openRouterThinkingLevel); + streamFn = createOpenRouterSystemCacheWrapper(streamFn); + return streamFn; + }, + isCacheTtlEligible: (ctx) => isOpenRouterCacheTtlModel(ctx.modelId), + }); + }, +}; + +export default openRouterPlugin; diff --git a/extensions/qwen-portal-auth/index.ts b/extensions/qwen-portal-auth/index.ts index 643663c1ffa..c5722e0dbf9 100644 --- a/extensions/qwen-portal-auth/index.ts +++ b/extensions/qwen-portal-auth/index.ts @@ -3,6 +3,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi, type ProviderAuthContext, + type ProviderCatalogContext, } from "openclaw/plugin-sdk/qwen-portal-auth"; import { loginQwenPortalOAuth } from "./oauth.js"; @@ -12,7 +13,6 @@ const DEFAULT_MODEL = "qwen-portal/coder-model"; const DEFAULT_BASE_URL = "https://portal.qwen.ai/v1"; const DEFAULT_CONTEXT_WINDOW = 128000; const DEFAULT_MAX_TOKENS = 8192; -const OAUTH_PLACEHOLDER = "qwen-oauth"; function normalizeBaseUrl(value: string | undefined): string { const raw = value?.trim() || DEFAULT_BASE_URL; @@ -36,6 +36,46 @@ function buildModelDefinition(params: { }; } +function buildProviderCatalog(params: { baseUrl: string; apiKey: string }) { + return { + baseUrl: params.baseUrl, + apiKey: params.apiKey, + api: "openai-completions" as const, + models: [ + buildModelDefinition({ + id: "coder-model", + name: "Qwen Coder", + input: ["text"], + }), + buildModelDefinition({ + id: "vision-model", + name: "Qwen Vision", + input: ["text", "image"], + }), + ], + }; +} + +function resolveCatalog(ctx: ProviderCatalogContext) { + const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID]; + const apiKey = + ctx.resolveProviderApiKey(PROVIDER_ID).apiKey ?? + (typeof explicitProvider?.apiKey === "string" ? explicitProvider.apiKey.trim() : undefined); + if (!apiKey) { + return null; + } + + const explicitBaseUrl = + typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl : undefined; + + return { + provider: buildProviderCatalog({ + baseUrl: normalizeBaseUrl(explicitBaseUrl), + apiKey, + }), + }; +} + const qwenPortalPlugin = { id: "qwen-portal-auth", name: "Qwen OAuth", @@ -47,6 +87,9 @@ const qwenPortalPlugin = { label: PROVIDER_LABEL, docsPath: "/providers/qwen", aliases: ["qwen"], + catalog: { + run: async (ctx: ProviderCatalogContext) => resolveCatalog(ctx), + }, auth: [ { id: "device", @@ -77,20 +120,7 @@ const qwenPortalPlugin = { providers: { [PROVIDER_ID]: { baseUrl, - apiKey: OAUTH_PLACEHOLDER, - api: "openai-completions", - models: [ - buildModelDefinition({ - id: "coder-model", - name: "Qwen Coder", - input: ["text"], - }), - buildModelDefinition({ - id: "vision-model", - name: "Qwen Vision", - input: ["text", "image"], - }), - ], + models: [], }, }, }, diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index bda8ac664db..9bb1bf76eff 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -61,21 +61,6 @@ function createOpenAITemplateModel(id: string): Model { } as Model; } -function createOpenAICodexTemplateModel(id: string): Model { - return { - id, - name: id, - provider: "openai-codex", - api: "openai-codex-responses", - baseUrl: "https://chatgpt.com/backend-api", - input: ["text", "image"], - reasoning: true, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 272_000, - maxTokens: 128_000, - } as Model; -} - function createRegistry(models: Record>): ModelRegistry { return { find(provider: string, modelId: string) { @@ -451,18 +436,6 @@ describe("resolveForwardCompatModel", () => { expect(model?.maxTokens).toBe(128_000); }); - it("resolves openai-codex gpt-5.4 via codex template fallback", () => { - const registry = createRegistry({ - "openai-codex/gpt-5.2-codex": createOpenAICodexTemplateModel("gpt-5.2-codex"), - }); - const model = resolveForwardCompatModel("openai-codex", "gpt-5.4", registry); - expectResolvedForwardCompat(model, { provider: "openai-codex", id: "gpt-5.4" }); - expect(model?.api).toBe("openai-codex-responses"); - expect(model?.baseUrl).toBe("https://chatgpt.com/backend-api"); - expect(model?.contextWindow).toBe(1_050_000); - expect(model?.maxTokens).toBe(128_000); - }); - it("resolves anthropic opus 4.6 via 4.5 template", () => { const registry = createRegistry({ "anthropic/claude-opus-4-5": createTemplateModel("anthropic", "claude-opus-4-5"), diff --git a/src/agents/model-forward-compat.ts b/src/agents/model-forward-compat.ts index 4afaff4a7a9..709afc2ee4d 100644 --- a/src/agents/model-forward-compat.ts +++ b/src/agents/model-forward-compat.ts @@ -11,16 +11,6 @@ const OPENAI_GPT_54_MAX_TOKENS = 128_000; const OPENAI_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.2"] as const; const OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS = ["gpt-5.2-pro", "gpt-5.2"] as const; -const OPENAI_CODEX_GPT_54_MODEL_ID = "gpt-5.4"; -const OPENAI_CODEX_GPT_54_CONTEXT_TOKENS = 1_050_000; -const OPENAI_CODEX_GPT_54_MAX_TOKENS = 128_000; -const OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.3-codex", "gpt-5.2-codex"] as const; -const OPENAI_CODEX_GPT_53_MODEL_ID = "gpt-5.3-codex"; -const OPENAI_CODEX_GPT_53_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; -const OPENAI_CODEX_GPT_53_SPARK_CONTEXT_TOKENS = 128_000; -const OPENAI_CODEX_GPT_53_SPARK_MAX_TOKENS = 128_000; -const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const; - const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6"; const ANTHROPIC_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6"; const ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] as const; @@ -114,79 +104,6 @@ function cloneFirstTemplateModel(params: { return undefined; } -const CODEX_GPT54_ELIGIBLE_PROVIDERS = new Set(["openai-codex"]); -const CODEX_GPT53_ELIGIBLE_PROVIDERS = new Set(["openai-codex", "github-copilot"]); - -function resolveOpenAICodexForwardCompatModel( - provider: string, - modelId: string, - modelRegistry: ModelRegistry, -): Model | undefined { - const normalizedProvider = normalizeProviderId(provider); - const trimmedModelId = modelId.trim(); - const lower = trimmedModelId.toLowerCase(); - - let templateIds: readonly string[]; - let eligibleProviders: Set; - let patch: Partial> | undefined; - if (lower === OPENAI_CODEX_GPT_54_MODEL_ID) { - templateIds = OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS; - eligibleProviders = CODEX_GPT54_ELIGIBLE_PROVIDERS; - patch = { - contextWindow: OPENAI_CODEX_GPT_54_CONTEXT_TOKENS, - maxTokens: OPENAI_CODEX_GPT_54_MAX_TOKENS, - }; - } else if (lower === OPENAI_CODEX_GPT_53_SPARK_MODEL_ID) { - templateIds = [OPENAI_CODEX_GPT_53_MODEL_ID, ...OPENAI_CODEX_TEMPLATE_MODEL_IDS]; - eligibleProviders = CODEX_GPT54_ELIGIBLE_PROVIDERS; - patch = { - api: "openai-codex-responses", - provider: normalizedProvider, - baseUrl: "https://chatgpt.com/backend-api", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: OPENAI_CODEX_GPT_53_SPARK_CONTEXT_TOKENS, - maxTokens: OPENAI_CODEX_GPT_53_SPARK_MAX_TOKENS, - }; - } else if (lower === OPENAI_CODEX_GPT_53_MODEL_ID) { - templateIds = OPENAI_CODEX_TEMPLATE_MODEL_IDS; - eligibleProviders = CODEX_GPT53_ELIGIBLE_PROVIDERS; - } else { - return undefined; - } - - if (!eligibleProviders.has(normalizedProvider)) { - return undefined; - } - - for (const templateId of templateIds) { - const template = modelRegistry.find(normalizedProvider, templateId) as Model | null; - if (!template) { - continue; - } - return normalizeModelCompat({ - ...template, - id: trimmedModelId, - name: trimmedModelId, - ...patch, - } as Model); - } - - return normalizeModelCompat({ - id: trimmedModelId, - name: trimmedModelId, - api: "openai-codex-responses", - provider: normalizedProvider, - baseUrl: "https://chatgpt.com/backend-api", - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: patch?.contextWindow ?? DEFAULT_CONTEXT_TOKENS, - maxTokens: patch?.maxTokens ?? DEFAULT_CONTEXT_TOKENS, - } as Model); -} - function resolveAnthropic46ForwardCompatModel(params: { provider: string; modelId: string; @@ -348,7 +265,6 @@ export function resolveForwardCompatModel( ): Model | undefined { return ( resolveOpenAIGpt54ForwardCompatModel(provider, modelId, modelRegistry) ?? - resolveOpenAICodexForwardCompatModel(provider, modelId, modelRegistry) ?? resolveAnthropicOpus46ForwardCompatModel(provider, modelId, modelRegistry) ?? resolveAnthropicSonnet46ForwardCompatModel(provider, modelId, modelRegistry) ?? resolveZaiGlm5ForwardCompatModel(provider, modelId, modelRegistry) ?? diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 19d2f1327ba..29ffd29e87c 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -1,9 +1,5 @@ import type { OpenClawConfig } from "../config/config.js"; import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js"; -import { - DEFAULT_COPILOT_API_BASE_URL, - resolveCopilotApiToken, -} from "../providers/github-copilot-token.js"; import { isRecord } from "../utils.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; @@ -32,8 +28,6 @@ import { buildModelStudioProvider, buildMoonshotProvider, buildNvidiaProvider, - buildOpenAICodexProvider, - buildOpenrouterProvider, buildQianfanProvider, buildQwenPortalProvider, buildSyntheticProvider, @@ -60,6 +54,7 @@ import { groupPluginDiscoveryProvidersByOrder, normalizePluginDiscoveryResult, resolvePluginDiscoveryProviders, + runProviderCatalog, } from "../plugins/provider-discovery.js"; import { MINIMAX_OAUTH_MARKER, @@ -762,7 +757,6 @@ const SIMPLE_IMPLICIT_PROVIDER_LOADERS: ImplicitProviderLoader[] = [ apiKey, }; }), - withApiKey("openrouter", async ({ apiKey }) => ({ ...buildOpenrouterProvider(), apiKey })), withApiKey("nvidia", async ({ apiKey }) => ({ ...buildNvidiaProvider(), apiKey })), withApiKey("kilocode", async ({ apiKey }) => ({ ...(await buildKilocodeProviderWithDiscovery()), @@ -788,7 +782,6 @@ const PROFILE_IMPLICIT_PROVIDER_LOADERS: ImplicitProviderLoader[] = [ ...buildQwenPortalProvider(), apiKey: QWEN_OAUTH_MARKER, })), - withProfilePresence("openai-codex", async () => buildOpenAICodexProvider()), ]; const PAIRED_IMPLICIT_PROVIDER_LOADERS: ImplicitProviderLoader[] = [ @@ -868,7 +861,8 @@ async function resolvePluginImplicitProviders( const byOrder = groupPluginDiscoveryProvidersByOrder(providers); const discovered: Record = {}; for (const provider of byOrder[order]) { - const result = await provider.discovery?.run({ + const result = await runProviderCatalog({ + provider, config: ctx.config ?? {}, agentDir: ctx.agentDir, workspaceDir: ctx.workspaceDir, @@ -933,16 +927,6 @@ export async function resolveImplicitProviders( mergeImplicitProviderSet(providers, await resolveCloudflareAiGatewayImplicitProvider(context)); mergeImplicitProviderSet(providers, await resolvePluginImplicitProviders(context, "late")); - if (!providers["github-copilot"]) { - const implicitCopilot = await resolveImplicitCopilotProvider({ - agentDir: params.agentDir, - env, - }); - if (implicitCopilot) { - providers["github-copilot"] = implicitCopilot; - } - } - const implicitBedrock = await resolveImplicitBedrockProvider({ agentDir: params.agentDir, config: params.config, @@ -965,64 +949,6 @@ export async function resolveImplicitProviders( return providers; } -export async function resolveImplicitCopilotProvider(params: { - agentDir: string; - env?: NodeJS.ProcessEnv; -}): Promise { - const env = params.env ?? process.env; - const authStore = ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: false, - }); - const hasProfile = listProfilesForProvider(authStore, "github-copilot").length > 0; - const envToken = env.COPILOT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN; - const githubToken = (envToken ?? "").trim(); - - if (!hasProfile && !githubToken) { - return null; - } - - let selectedGithubToken = githubToken; - if (!selectedGithubToken && hasProfile) { - // Use the first available profile as a default for discovery (it will be - // re-resolved per-run by the embedded runner). - const profileId = listProfilesForProvider(authStore, "github-copilot")[0]; - const profile = profileId ? authStore.profiles[profileId] : undefined; - if (profile && profile.type === "token") { - selectedGithubToken = profile.token?.trim() ?? ""; - if (!selectedGithubToken) { - const tokenRef = coerceSecretRef(profile.tokenRef); - if (tokenRef?.source === "env" && tokenRef.id.trim()) { - selectedGithubToken = (env[tokenRef.id] ?? process.env[tokenRef.id] ?? "").trim(); - } - } - } - } - - let baseUrl = DEFAULT_COPILOT_API_BASE_URL; - if (selectedGithubToken) { - try { - const token = await resolveCopilotApiToken({ - githubToken: selectedGithubToken, - env, - }); - baseUrl = token.baseUrl; - } catch { - baseUrl = DEFAULT_COPILOT_API_BASE_URL; - } - } - - // We deliberately do not write pi-coding-agent auth.json here. - // OpenClaw keeps auth in auth-profiles and resolves runtime availability from that store. - - // We intentionally do NOT define custom models for Copilot in models.json. - // pi-coding-agent treats providers with models as replacements requiring apiKey. - // We only override baseUrl; the model list comes from pi-ai built-ins. - return { - baseUrl, - models: [], - } satisfies ProviderConfig; -} - export async function resolveImplicitBedrockProvider(params: { agentDir: string; config?: OpenClawConfig; diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 7a29f30f9eb..c4790e37dba 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -1,6 +1,73 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import type { Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; + +vi.mock("../plugins/provider-runtime.js", async (importOriginal) => { + const actual = await importOriginal(); + const { + createOpenRouterSystemCacheWrapper, + createOpenRouterWrapper, + isProxyReasoningUnsupported, + } = await import("./pi-embedded-runner/proxy-stream-wrappers.js"); + + return { + ...actual, + prepareProviderExtraParams: (params: { + provider: string; + context: { extraParams?: Record }; + }) => { + if (params.provider !== "openai-codex") { + return undefined; + } + const transport = params.context.extraParams?.transport; + if (transport === "auto" || transport === "sse" || transport === "websocket") { + return params.context.extraParams; + } + return { + ...params.context.extraParams, + transport: "auto", + }; + }, + wrapProviderStreamFn: (params: { + provider: string; + context: { + modelId: string; + thinkingLevel?: import("../auto-reply/thinking.js").ThinkLevel; + extraParams?: Record; + streamFn?: StreamFn; + }; + }) => { + if (params.provider !== "openrouter") { + return params.context.streamFn; + } + + const providerRouting = + params.context.extraParams?.provider != null && + typeof params.context.extraParams.provider === "object" + ? (params.context.extraParams.provider as Record) + : undefined; + let streamFn = params.context.streamFn; + if (providerRouting) { + const underlying = streamFn; + streamFn = (model, context, options) => + (underlying as StreamFn)( + { + ...model, + compat: { ...model.compat, openRouterRouting: providerRouting }, + }, + context, + options, + ); + } + + const skipReasoningInjection = + params.context.modelId === "auto" || isProxyReasoningUnsupported(params.context.modelId); + const thinkingLevel = skipReasoningInjection ? undefined : params.context.thinkingLevel; + return createOpenRouterSystemCacheWrapper(createOpenRouterWrapper(streamFn, thinkingLevel)); + }, + }; +}); + import { applyExtraParamsToAgent, resolveExtraParams } from "./pi-embedded-runner.js"; import { log } from "./pi-embedded-runner/logger.js"; diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts index 0aa665e0635..f9f9934f453 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts @@ -38,6 +38,30 @@ vi.mock("../providers/github-copilot-token.js", () => ({ resolveCopilotApiToken: (...args: unknown[]) => resolveCopilotApiTokenMock(...args), })); +vi.mock("../plugins/provider-runtime.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + prepareProviderRuntimeAuth: async (params: { + provider: string; + context: { apiKey: string; env: NodeJS.ProcessEnv }; + }) => { + if (params.provider !== "github-copilot") { + return undefined; + } + const token = await resolveCopilotApiTokenMock({ + githubToken: params.context.apiKey, + env: params.context.env, + }); + return { + apiKey: token.token, + baseUrl: token.baseUrl, + expiresAt: token.expiresAt, + }; + }, + }; +}); + vi.mock("./pi-embedded-runner/compact.js", () => ({ compactEmbeddedPiSessionDirect: vi.fn(async () => { throw new Error("compact should not run in auth profile rotation tests"); diff --git a/src/agents/pi-embedded-runner/cache-ttl.test.ts b/src/agents/pi-embedded-runner/cache-ttl.test.ts index 02945cab8ba..d968b6b79eb 100644 --- a/src/agents/pi-embedded-runner/cache-ttl.test.ts +++ b/src/agents/pi-embedded-runner/cache-ttl.test.ts @@ -1,4 +1,16 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../plugins/provider-runtime.js", () => ({ + resolveProviderCacheTtlEligibility: (params: { + context: { provider: string; modelId: string }; + }) => + params.context.provider === "openrouter" + ? ["anthropic/", "moonshot/", "moonshotai/", "zai/"].some((prefix) => + params.context.modelId.startsWith(prefix), + ) + : undefined, +})); + import { isCacheTtlEligibleProvider } from "./cache-ttl.js"; describe("isCacheTtlEligibleProvider", () => { diff --git a/src/agents/pi-embedded-runner/cache-ttl.ts b/src/agents/pi-embedded-runner/cache-ttl.ts index 53231bdc605..e971f564edd 100644 --- a/src/agents/pi-embedded-runner/cache-ttl.ts +++ b/src/agents/pi-embedded-runner/cache-ttl.ts @@ -1,3 +1,5 @@ +import { resolveProviderCacheTtlEligibility } from "../../plugins/provider-runtime.js"; + type CustomEntryLike = { type?: unknown; customType?: unknown; data?: unknown }; export const CACHE_TTL_CUSTOM_TYPE = "openclaw.cache-ttl"; @@ -9,24 +11,21 @@ export type CacheTtlEntryData = { }; const CACHE_TTL_NATIVE_PROVIDERS = new Set(["anthropic", "moonshot", "zai"]); -const OPENROUTER_CACHE_TTL_MODEL_PREFIXES = [ - "anthropic/", - "moonshot/", - "moonshotai/", - "zai/", -] as const; - -function isOpenRouterCacheTtlModel(modelId: string): boolean { - return OPENROUTER_CACHE_TTL_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix)); -} export function isCacheTtlEligibleProvider(provider: string, modelId: string): boolean { const normalizedProvider = provider.toLowerCase(); const normalizedModelId = modelId.toLowerCase(); - if (CACHE_TTL_NATIVE_PROVIDERS.has(normalizedProvider)) { - return true; + const pluginEligibility = resolveProviderCacheTtlEligibility({ + provider: normalizedProvider, + context: { + provider: normalizedProvider, + modelId: normalizedModelId, + }, + }); + if (pluginEligibility !== undefined) { + return pluginEligibility; } - if (normalizedProvider === "openrouter" && isOpenRouterCacheTtlModel(normalizedModelId)) { + if (CACHE_TTL_NATIVE_PROVIDERS.has(normalizedProvider)) { return true; } if (normalizedProvider === "kilocode" && normalizedModelId.startsWith("anthropic/")) { diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 89f3d4a066a..908c323c676 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -23,6 +23,7 @@ import { getMachineDisplayName } from "../../infra/machine-name.js"; import { generateSecureToken } from "../../infra/secure-random.js"; import { getMemorySearchManager } from "../../memory/index.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; +import { prepareProviderRuntimeAuth } from "../../plugins/provider-runtime.js"; import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js"; import { isCronSessionKey, isSubagentSessionKey } from "../../routing/session-key.js"; import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; @@ -434,10 +435,11 @@ export async function compactEmbeddedPiSessionDirect( const reason = error ?? `Unknown model: ${provider}/${modelId}`; return fail(reason); } + let runtimeModel = model; let apiKeyInfo: Awaited> | null = null; try { apiKeyInfo = await getApiKeyForModel({ - model, + model: runtimeModel, cfg: params.config, profileId: authProfileId, agentDir, @@ -446,17 +448,36 @@ export async function compactEmbeddedPiSessionDirect( if (!apiKeyInfo.apiKey) { if (apiKeyInfo.mode !== "aws-sdk") { throw new Error( - `No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`, + `No API key resolved for provider "${runtimeModel.provider}" (auth mode: ${apiKeyInfo.mode}).`, ); } - } else if (model.provider === "github-copilot") { - const { resolveCopilotApiToken } = await import("../../providers/github-copilot-token.js"); - const copilotToken = await resolveCopilotApiToken({ - githubToken: apiKeyInfo.apiKey, - }); - authStorage.setRuntimeApiKey(model.provider, copilotToken.token); } else { - authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); + const preparedAuth = await prepareProviderRuntimeAuth({ + provider: runtimeModel.provider, + config: params.config, + workspaceDir: resolvedWorkspace, + env: process.env, + context: { + config: params.config, + agentDir, + workspaceDir: resolvedWorkspace, + env: process.env, + provider: runtimeModel.provider, + modelId, + model: runtimeModel, + apiKey: apiKeyInfo.apiKey, + authMode: apiKeyInfo.mode, + profileId: apiKeyInfo.profileId, + }, + }); + if (preparedAuth?.baseUrl) { + runtimeModel = { ...runtimeModel, baseUrl: preparedAuth.baseUrl }; + } + const runtimeApiKey = preparedAuth?.apiKey ?? apiKeyInfo.apiKey; + if (!runtimeApiKey) { + throw new Error(`Provider "${runtimeModel.provider}" runtime auth returned no apiKey.`); + } + authStorage.setRuntimeApiKey(runtimeModel.provider, runtimeApiKey); } } catch (err) { const reason = describeUnknownError(err); @@ -521,13 +542,13 @@ export async function compactEmbeddedPiSessionDirect( cfg: params.config, provider, modelId, - modelContextWindow: model.contextWindow, + modelContextWindow: runtimeModel.contextWindow, defaultTokens: DEFAULT_CONTEXT_TOKENS, }); const effectiveModel = applyLocalNoAuthHeaderOverride( - ctxInfo.tokens < (model.contextWindow ?? Infinity) - ? { ...model, contextWindow: ctxInfo.tokens } - : model, + ctxInfo.tokens < (runtimeModel.contextWindow ?? Infinity) + ? { ...runtimeModel, contextWindow: ctxInfo.tokens } + : runtimeModel, apiKeyInfo, ); @@ -557,7 +578,7 @@ export async function compactEmbeddedPiSessionDirect( modelAuthMode: resolveModelAuthMode(model.provider, params.config), }); const tools = sanitizeToolsForGoogle({ - tools: supportsModelTools(model) ? toolsRaw : [], + tools: supportsModelTools(runtimeModel) ? toolsRaw : [], provider, }); const allowedToolNames = collectAllowedToolNames({ tools }); diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index a9d5085e013..be773071fbe 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -3,6 +3,10 @@ import type { SimpleStreamOptions } from "@mariozechner/pi-ai"; import { streamSimple } from "@mariozechner/pi-ai"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { + prepareProviderExtraParams, + wrapProviderStreamFn, +} from "../../plugins/provider-runtime.js"; import { createAnthropicBetaHeadersWrapper, createAnthropicFastModeWrapper, @@ -22,7 +26,6 @@ import { shouldApplySiliconFlowThinkingOffCompat, } from "./moonshot-stream-wrappers.js"; import { - createCodexDefaultTransportWrapper, createOpenAIDefaultTransportWrapper, createOpenAIFastModeWrapper, createOpenAIResponsesContextManagementWrapper, @@ -30,12 +33,7 @@ import { resolveOpenAIFastMode, resolveOpenAIServiceTier, } from "./openai-stream-wrappers.js"; -import { - createKilocodeWrapper, - createOpenRouterSystemCacheWrapper, - createOpenRouterWrapper, - isProxyReasoningUnsupported, -} from "./proxy-stream-wrappers.js"; +import { createKilocodeWrapper, isProxyReasoningUnsupported } from "./proxy-stream-wrappers.js"; /** * Resolve provider-specific extra params from model config. @@ -111,39 +109,15 @@ function createStreamFnWithExtraParams( streamParams.cacheRetention = cacheRetention; } - // Extract OpenRouter provider routing preferences from extraParams.provider. - // Injected into model.compat.openRouterRouting so pi-ai's buildParams sets - // params.provider in the API request body (openai-completions.js L359-362). - // pi-ai's OpenRouterRouting type only declares { only?, order? }, but at - // runtime the full object is forwarded — enabling allow_fallbacks, - // data_collection, ignore, sort, quantizations, etc. - const providerRouting = - provider === "openrouter" && - extraParams.provider != null && - typeof extraParams.provider === "object" - ? (extraParams.provider as Record) - : undefined; - - if (Object.keys(streamParams).length === 0 && !providerRouting) { + if (Object.keys(streamParams).length === 0) { return undefined; } log.debug(`creating streamFn wrapper with params: ${JSON.stringify(streamParams)}`); - if (providerRouting) { - log.debug(`OpenRouter provider routing: ${JSON.stringify(providerRouting)}`); - } const underlying = baseStreamFn ?? streamSimple; const wrappedStreamFn: StreamFn = (model, context, options) => { - // When provider routing is configured, inject it into model.compat so - // pi-ai picks it up via model.compat.openRouterRouting. - const effectiveModel = providerRouting - ? ({ - ...model, - compat: { ...model.compat, openRouterRouting: providerRouting }, - } as unknown as typeof model) - : model; - return underlying(effectiveModel, context, { + return underlying(model, context, { ...streamParams, ...options, }); @@ -342,13 +316,6 @@ export function applyExtraParamsToAgent( modelId, agentId, }); - if (provider === "openai-codex") { - // Default Codex to WebSocket-first when nothing else specifies transport. - agent.streamFn = createCodexDefaultTransportWrapper(agent.streamFn); - } else if (provider === "openai") { - // Default OpenAI Responses to WebSocket-first with transparent SSE fallback. - agent.streamFn = createOpenAIDefaultTransportWrapper(agent.streamFn); - } const override = extraParamsOverride && Object.keys(extraParamsOverride).length > 0 ? Object.fromEntries( @@ -356,14 +323,35 @@ export function applyExtraParamsToAgent( ) : undefined; const merged = Object.assign({}, resolvedExtraParams, override); - const wrappedStreamFn = createStreamFnWithExtraParams(agent.streamFn, merged, provider); + const effectiveExtraParams = + prepareProviderExtraParams({ + provider, + config: cfg, + context: { + config: cfg, + provider, + modelId, + extraParams: merged, + thinkingLevel, + }, + }) ?? merged; + + if (provider === "openai") { + // Default OpenAI Responses to WebSocket-first with transparent SSE fallback. + agent.streamFn = createOpenAIDefaultTransportWrapper(agent.streamFn); + } + const wrappedStreamFn = createStreamFnWithExtraParams( + agent.streamFn, + effectiveExtraParams, + provider, + ); if (wrappedStreamFn) { log.debug(`applying extraParams to agent streamFn for ${provider}/${modelId}`); agent.streamFn = wrappedStreamFn; } - const anthropicBetas = resolveAnthropicBetas(merged, provider, modelId); + const anthropicBetas = resolveAnthropicBetas(effectiveExtraParams, provider, modelId); if (anthropicBetas?.length) { log.debug( `applying Anthropic beta header for ${provider}/${modelId}: ${anthropicBetas.join(",")}`, @@ -380,7 +368,7 @@ export function applyExtraParamsToAgent( if (shouldApplyMoonshotPayloadCompat({ provider, modelId })) { const moonshotThinkingType = resolveMoonshotThinkingType({ - configuredThinking: merged?.thinking, + configuredThinking: effectiveExtraParams?.thinking, thinkingLevel, }); if (moonshotThinkingType) { @@ -392,25 +380,19 @@ export function applyExtraParamsToAgent( } agent.streamFn = createAnthropicToolPayloadCompatibilityWrapper(agent.streamFn); - - if (provider === "openrouter") { - log.debug(`applying OpenRouter app attribution headers for ${provider}/${modelId}`); - // "auto" is a dynamic routing model — we don't know which underlying model - // OpenRouter will select, and it may be a reasoning-required endpoint. - // Omit the thinkingLevel so we never inject `reasoning.effort: "none"`, - // which would cause a 400 on models where reasoning is mandatory. - // Users who need reasoning control should target a specific model ID. - // See: openclaw/openclaw#24851 - // - // x-ai/grok models do not support OpenRouter's reasoning.effort parameter - // and reject payloads containing it with "Invalid arguments passed to the - // model." Skip reasoning injection for these models. - // See: openclaw/openclaw#32039 - const skipReasoningInjection = modelId === "auto" || isProxyReasoningUnsupported(modelId); - const openRouterThinkingLevel = skipReasoningInjection ? undefined : thinkingLevel; - agent.streamFn = createOpenRouterWrapper(agent.streamFn, openRouterThinkingLevel); - agent.streamFn = createOpenRouterSystemCacheWrapper(agent.streamFn); - } + agent.streamFn = + wrapProviderStreamFn({ + provider, + config: cfg, + context: { + config: cfg, + provider, + modelId, + extraParams: effectiveExtraParams, + thinkingLevel, + streamFn: agent.streamFn, + }, + }) ?? agent.streamFn; if (provider === "kilocode") { log.debug(`applying Kilocode feature header for ${provider}/${modelId}`); @@ -430,7 +412,7 @@ export function applyExtraParamsToAgent( // Enable Z.AI tool_stream for real-time tool call streaming. // Enabled by default for Z.AI provider, can be disabled via params.tool_stream: false if (provider === "zai" || provider === "z-ai") { - const toolStreamEnabled = merged?.tool_stream !== false; + const toolStreamEnabled = effectiveExtraParams?.tool_stream !== false; if (toolStreamEnabled) { log.debug(`enabling Z.AI tool_stream for ${provider}/${modelId}`); agent.streamFn = createZaiToolStreamWrapper(agent.streamFn, true); @@ -441,19 +423,19 @@ export function applyExtraParamsToAgent( // upstream model-ID heuristics for Gemini 3.1 variants. agent.streamFn = createGoogleThinkingPayloadWrapper(agent.streamFn, thinkingLevel); - const anthropicFastMode = resolveAnthropicFastMode(merged); + const anthropicFastMode = resolveAnthropicFastMode(effectiveExtraParams); if (anthropicFastMode !== undefined) { log.debug(`applying Anthropic fast mode=${anthropicFastMode} for ${provider}/${modelId}`); agent.streamFn = createAnthropicFastModeWrapper(agent.streamFn, anthropicFastMode); } - const openAIFastMode = resolveOpenAIFastMode(merged); + const openAIFastMode = resolveOpenAIFastMode(effectiveExtraParams); if (openAIFastMode) { log.debug(`applying OpenAI fast mode for ${provider}/${modelId}`); agent.streamFn = createOpenAIFastModeWrapper(agent.streamFn); } - const openAIServiceTier = resolveOpenAIServiceTier(merged); + const openAIServiceTier = resolveOpenAIServiceTier(effectiveExtraParams); if (openAIServiceTier) { log.debug(`applying OpenAI service_tier=${openAIServiceTier} for ${provider}/${modelId}`); agent.streamFn = createOpenAIServiceTierWrapper(agent.streamFn, openAIServiceTier); @@ -462,7 +444,10 @@ export function applyExtraParamsToAgent( // Work around upstream pi-ai hardcoding `store: false` for Responses API. // Force `store=true` for direct OpenAI Responses models and auto-enable // server-side compaction for compatible OpenAI Responses payloads. - agent.streamFn = createOpenAIResponsesContextManagementWrapper(agent.streamFn, merged); + agent.streamFn = createOpenAIResponsesContextManagementWrapper( + agent.streamFn, + effectiveExtraParams, + ); const rawParallelToolCalls = resolveAliasedParamValue( [resolvedExtraParams, override], diff --git a/src/agents/pi-embedded-runner/model.provider-normalization.ts b/src/agents/pi-embedded-runner/model.provider-normalization.ts index 82dabff7c1b..3b6f67d3946 100644 --- a/src/agents/pi-embedded-runner/model.provider-normalization.ts +++ b/src/agents/pi-embedded-runner/model.provider-normalization.ts @@ -2,8 +2,6 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import { normalizeModelCompat } from "../model-compat.js"; import { normalizeProviderId } from "../model-selection.js"; -const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; - function isOpenAIApiBaseUrl(baseUrl?: string): boolean { const trimmed = baseUrl?.trim(); if (!trimmed) { @@ -12,48 +10,6 @@ function isOpenAIApiBaseUrl(baseUrl?: string): boolean { return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/i.test(trimmed); } -function isOpenAICodexBaseUrl(baseUrl?: string): boolean { - const trimmed = baseUrl?.trim(); - if (!trimmed) { - return false; - } - return /^https?:\/\/chatgpt\.com\/backend-api\/?$/i.test(trimmed); -} - -function normalizeOpenAICodexTransport(params: { - provider: string; - model: Model; -}): Model { - if (normalizeProviderId(params.provider) !== "openai-codex") { - return params.model; - } - - const useCodexTransport = - !params.model.baseUrl || - isOpenAIApiBaseUrl(params.model.baseUrl) || - isOpenAICodexBaseUrl(params.model.baseUrl); - - const nextApi = - useCodexTransport && params.model.api === "openai-responses" - ? ("openai-codex-responses" as const) - : params.model.api; - const nextBaseUrl = - nextApi === "openai-codex-responses" && - (!params.model.baseUrl || isOpenAIApiBaseUrl(params.model.baseUrl)) - ? OPENAI_CODEX_BASE_URL - : params.model.baseUrl; - - if (nextApi === params.model.api && nextBaseUrl === params.model.baseUrl) { - return params.model; - } - - return { - ...params.model, - api: nextApi, - baseUrl: nextBaseUrl, - } as Model; -} - function normalizeOpenAITransport(params: { provider: string; model: Model }): Model { if (normalizeProviderId(params.provider) !== "openai") { return params.model; @@ -73,14 +29,16 @@ function normalizeOpenAITransport(params: { provider: string; model: Model } as Model; } +export function applyBuiltInResolvedProviderTransportNormalization(params: { + provider: string; + model: Model; +}): Model { + return normalizeOpenAITransport(params); +} + export function normalizeResolvedProviderModel(params: { provider: string; model: Model; }): Model { - const normalizedOpenAI = normalizeOpenAITransport(params); - const normalizedCodex = normalizeOpenAICodexTransport({ - provider: params.provider, - model: normalizedOpenAI, - }); - return normalizeModelCompat(normalizedCodex); + return normalizeModelCompat(applyBuiltInResolvedProviderTransportNormalization(params)); } diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 2ead43e96e0..1a36178f9ce 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -2,10 +2,17 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import type { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent"; import type { OpenClawConfig } from "../../config/config.js"; import type { ModelDefinitionConfig } from "../../config/types.js"; +import { + prepareProviderDynamicModel, + resolveProviderRuntimePlugin, + runProviderDynamicModel, + normalizeProviderResolvedModelWithPlugin, +} from "../../plugins/provider-runtime.js"; import { resolveOpenClawAgentDir } from "../agent-paths.js"; import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; import { buildModelAliasLines } from "../model-alias-lines.js"; import { isSecretRefHeaderValueMarker } from "../model-auth-markers.js"; +import { normalizeModelCompat } from "../model-compat.js"; import { resolveForwardCompatModel } from "../model-forward-compat.js"; import { findNormalizedProviderValue, normalizeProviderId } from "../model-selection.js"; import { @@ -14,10 +21,6 @@ import { } from "../model-suppression.js"; import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js"; import { normalizeResolvedProviderModel } from "./model.provider-normalization.js"; -import { - getOpenRouterModelCapabilities, - loadOpenRouterModelCapabilities, -} from "./openrouter-model-capabilities.js"; type InlineModelEntry = ModelDefinitionConfig & { provider: string; @@ -51,7 +54,26 @@ function sanitizeModelHeaders( return Object.keys(next).length > 0 ? next : undefined; } -function normalizeResolvedModel(params: { provider: string; model: Model }): Model { +function normalizeResolvedModel(params: { + provider: string; + model: Model; + cfg?: OpenClawConfig; + agentDir?: string; +}): Model { + const pluginNormalized = normalizeProviderResolvedModelWithPlugin({ + provider: params.provider, + config: params.cfg, + context: { + config: params.cfg, + agentDir: params.agentDir, + provider: params.provider, + modelId: params.model.id, + model: params.model, + }, + }); + if (pluginNormalized) { + return normalizeModelCompat(pluginNormalized); + } return normalizeResolvedProviderModel(params); } @@ -165,8 +187,9 @@ function resolveExplicitModelWithRegistry(params: { modelId: string; modelRegistry: ModelRegistry; cfg?: OpenClawConfig; + agentDir?: string; }): { kind: "resolved"; model: Model } | { kind: "suppressed" } | undefined { - const { provider, modelId, modelRegistry, cfg } = params; + const { provider, modelId, modelRegistry, cfg, agentDir } = params; if (shouldSuppressBuiltInModel({ provider, id: modelId })) { return { kind: "suppressed" }; } @@ -178,6 +201,8 @@ function resolveExplicitModelWithRegistry(params: { kind: "resolved", model: normalizeResolvedModel({ provider, + cfg, + agentDir, model: applyConfiguredProviderOverrides({ discoveredModel: model, providerConfig, @@ -196,7 +221,12 @@ function resolveExplicitModelWithRegistry(params: { if (inlineMatch?.api) { return { kind: "resolved", - model: normalizeResolvedModel({ provider, model: inlineMatch as Model }), + model: normalizeResolvedModel({ + provider, + cfg, + agentDir, + model: inlineMatch as Model, + }), }; } @@ -208,6 +238,8 @@ function resolveExplicitModelWithRegistry(params: { kind: "resolved", model: normalizeResolvedModel({ provider, + cfg, + agentDir, model: applyConfiguredProviderOverrides({ discoveredModel: forwardCompat, providerConfig, @@ -225,6 +257,7 @@ export function resolveModelWithRegistry(params: { modelId: string; modelRegistry: ModelRegistry; cfg?: OpenClawConfig; + agentDir?: string; }): Model | undefined { const explicitModel = resolveExplicitModelWithRegistry(params); if (explicitModel?.kind === "suppressed") { @@ -234,31 +267,26 @@ export function resolveModelWithRegistry(params: { return explicitModel.model; } - const { provider, modelId, cfg } = params; - const normalizedProvider = normalizeProviderId(provider); + const { provider, modelId, cfg, modelRegistry, agentDir } = params; const providerConfig = resolveConfiguredProviderConfig(cfg, provider); - - // OpenRouter is a pass-through proxy - any model ID available on OpenRouter - // should work without being pre-registered in the local catalog. - // Try to fetch actual capabilities from the OpenRouter API so that new models - // (not yet in the static pi-ai snapshot) get correct image/reasoning support. - if (normalizedProvider === "openrouter") { - const capabilities = getOpenRouterModelCapabilities(modelId); + const pluginDynamicModel = runProviderDynamicModel({ + provider, + config: cfg, + context: { + config: cfg, + agentDir, + provider, + modelId, + modelRegistry, + providerConfig, + }, + }); + if (pluginDynamicModel) { return normalizeResolvedModel({ provider, - model: { - id: modelId, - name: capabilities?.name ?? modelId, - api: "openai-completions", - provider, - baseUrl: "https://openrouter.ai/api/v1", - reasoning: capabilities?.reasoning ?? false, - input: capabilities?.input ?? ["text"], - cost: capabilities?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: capabilities?.contextWindow ?? DEFAULT_CONTEXT_TOKENS, - // Align with OPENROUTER_DEFAULT_MAX_TOKENS in models-config.providers.ts - maxTokens: capabilities?.maxTokens ?? 8192, - } as Model, + cfg, + agentDir, + model: pluginDynamicModel, }); } @@ -272,6 +300,8 @@ export function resolveModelWithRegistry(params: { if (providerConfig || modelId.startsWith("mock-")) { return normalizeResolvedModel({ provider, + cfg, + agentDir, model: { id: modelId, name: modelId, @@ -312,7 +342,13 @@ export function resolveModel( const resolvedAgentDir = agentDir ?? resolveOpenClawAgentDir(); const authStorage = discoverAuthStorage(resolvedAgentDir); const modelRegistry = discoverModels(authStorage, resolvedAgentDir); - const model = resolveModelWithRegistry({ provider, modelId, modelRegistry, cfg }); + const model = resolveModelWithRegistry({ + provider, + modelId, + modelRegistry, + cfg, + agentDir: resolvedAgentDir, + }); if (model) { return { model, authStorage, modelRegistry }; } @@ -338,7 +374,13 @@ export async function resolveModelAsync( const resolvedAgentDir = agentDir ?? resolveOpenClawAgentDir(); const authStorage = discoverAuthStorage(resolvedAgentDir); const modelRegistry = discoverModels(authStorage, resolvedAgentDir); - const explicitModel = resolveExplicitModelWithRegistry({ provider, modelId, modelRegistry, cfg }); + const explicitModel = resolveExplicitModelWithRegistry({ + provider, + modelId, + modelRegistry, + cfg, + agentDir: resolvedAgentDir, + }); if (explicitModel?.kind === "suppressed") { return { error: buildUnknownModelError(provider, modelId), @@ -346,13 +388,36 @@ export async function resolveModelAsync( modelRegistry, }; } - if (!explicitModel && normalizeProviderId(provider) === "openrouter") { - await loadOpenRouterModelCapabilities(modelId); + if (!explicitModel) { + const providerPlugin = resolveProviderRuntimePlugin({ + provider, + config: cfg, + }); + if (providerPlugin?.prepareDynamicModel) { + await prepareProviderDynamicModel({ + provider, + config: cfg, + context: { + config: cfg, + agentDir: resolvedAgentDir, + provider, + modelId, + modelRegistry, + providerConfig: resolveConfiguredProviderConfig(cfg, provider), + }, + }); + } } const model = explicitModel?.kind === "resolved" ? explicitModel.model - : resolveModelWithRegistry({ provider, modelId, modelRegistry, cfg }); + : resolveModelWithRegistry({ + provider, + modelId, + modelRegistry, + cfg, + agentDir: resolvedAgentDir, + }); if (model) { return { model, authStorage, modelRegistry }; } diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 65d87712ca8..6ecf34ed93e 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -8,6 +8,7 @@ import { import { computeBackoff, sleepWithAbort, type BackoffPolicy } from "../../infra/backoff.js"; import { generateSecureToken } from "../../infra/secure-random.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; +import { prepareProviderRuntimeAuth } from "../../plugins/provider-runtime.js"; import type { PluginHookBeforeAgentStartResult } from "../../plugins/types.js"; import { enqueueCommandInLane } from "../../process/command-queue.js"; import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js"; @@ -80,16 +81,18 @@ import { describeUnknownError } from "./utils.js"; type ApiKeyInfo = ResolvedProviderAuth; -type CopilotTokenState = { - githubToken: string; - expiresAt: number; +type RuntimeAuthState = { + sourceApiKey: string; + authMode: string; + profileId?: string; + expiresAt?: number; refreshTimer?: ReturnType; refreshInFlight?: Promise; }; -const COPILOT_REFRESH_MARGIN_MS = 5 * 60 * 1000; -const COPILOT_REFRESH_RETRY_MS = 60 * 1000; -const COPILOT_REFRESH_MIN_DELAY_MS = 5 * 1000; +const RUNTIME_AUTH_REFRESH_MARGIN_MS = 5 * 60 * 1000; +const RUNTIME_AUTH_REFRESH_RETRY_MS = 60 * 1000; +const RUNTIME_AUTH_REFRESH_MIN_DELAY_MS = 5 * 1000; // Keep overload pacing noticeable enough to avoid tight retry bursts, but short // enough that fallback still feels responsive within a single turn. const OVERLOAD_FAILOVER_BACKOFF_POLICY: BackoffPolicy = { @@ -380,20 +383,21 @@ export async function runEmbeddedPiAgent( model: modelId, }); } + let runtimeModel = model; const ctxInfo = resolveContextWindowInfo({ cfg: params.config, provider, modelId, - modelContextWindow: model.contextWindow, + modelContextWindow: runtimeModel.contextWindow, defaultTokens: DEFAULT_CONTEXT_TOKENS, }); // Apply contextTokens cap to model so pi-coding-agent's auto-compaction // threshold uses the effective limit, not the native context window. - const effectiveModel = - ctxInfo.tokens < (model.contextWindow ?? Infinity) - ? { ...model, contextWindow: ctxInfo.tokens } - : model; + let effectiveModel = + ctxInfo.tokens < (runtimeModel.contextWindow ?? Infinity) + ? { ...runtimeModel, contextWindow: ctxInfo.tokens } + : runtimeModel; const ctxGuard = evaluateContextWindowGuard({ info: ctxInfo, warnBelowTokens: CONTEXT_WINDOW_WARN_BELOW_TOKENS, @@ -447,103 +451,142 @@ export async function runEmbeddedPiAgent( const attemptedThinking = new Set(); let apiKeyInfo: ApiKeyInfo | null = null; let lastProfileId: string | undefined; - const copilotTokenState: CopilotTokenState | null = - model.provider === "github-copilot" ? { githubToken: "", expiresAt: 0 } : null; - let copilotRefreshCancelled = false; - const hasCopilotGithubToken = () => Boolean(copilotTokenState?.githubToken.trim()); + let runtimeAuthState: RuntimeAuthState | null = null; + let runtimeAuthRefreshCancelled = false; + const hasRefreshableRuntimeAuth = () => Boolean(runtimeAuthState?.sourceApiKey.trim()); - const clearCopilotRefreshTimer = () => { - if (!copilotTokenState?.refreshTimer) { + const clearRuntimeAuthRefreshTimer = () => { + if (!runtimeAuthState?.refreshTimer) { return; } - clearTimeout(copilotTokenState.refreshTimer); - copilotTokenState.refreshTimer = undefined; + clearTimeout(runtimeAuthState.refreshTimer); + runtimeAuthState.refreshTimer = undefined; }; - const stopCopilotRefreshTimer = () => { - if (!copilotTokenState) { + const stopRuntimeAuthRefreshTimer = () => { + if (!runtimeAuthState) { return; } - copilotRefreshCancelled = true; - clearCopilotRefreshTimer(); + runtimeAuthRefreshCancelled = true; + clearRuntimeAuthRefreshTimer(); }; - const refreshCopilotToken = async (reason: string): Promise => { - if (!copilotTokenState) { + const refreshRuntimeAuth = async (reason: string): Promise => { + if (!runtimeAuthState) { return; } - if (copilotTokenState.refreshInFlight) { - await copilotTokenState.refreshInFlight; + if (runtimeAuthState.refreshInFlight) { + await runtimeAuthState.refreshInFlight; return; } - const { resolveCopilotApiToken } = await import("../../providers/github-copilot-token.js"); - copilotTokenState.refreshInFlight = (async () => { - const githubToken = copilotTokenState.githubToken.trim(); - if (!githubToken) { - throw new Error("Copilot refresh requires a GitHub token."); + runtimeAuthState.refreshInFlight = (async () => { + const sourceApiKey = runtimeAuthState?.sourceApiKey.trim() ?? ""; + if (!sourceApiKey) { + throw new Error(`Runtime auth refresh requires a source credential.`); } - log.debug(`Refreshing GitHub Copilot token (${reason})...`); - const copilotToken = await resolveCopilotApiToken({ - githubToken, + log.debug(`Refreshing runtime auth for ${runtimeModel.provider} (${reason})...`); + const preparedAuth = await prepareProviderRuntimeAuth({ + provider: runtimeModel.provider, + config: params.config, + workspaceDir: resolvedWorkspace, + env: process.env, + context: { + config: params.config, + agentDir, + workspaceDir: resolvedWorkspace, + env: process.env, + provider: runtimeModel.provider, + modelId, + model: runtimeModel, + apiKey: sourceApiKey, + authMode: runtimeAuthState?.authMode ?? "unknown", + profileId: runtimeAuthState?.profileId, + }, }); - authStorage.setRuntimeApiKey(model.provider, copilotToken.token); - copilotTokenState.expiresAt = copilotToken.expiresAt; - const remaining = copilotToken.expiresAt - Date.now(); - log.debug( - `Copilot token refreshed; expires in ${Math.max(0, Math.floor(remaining / 1000))}s.`, - ); + if (!preparedAuth?.apiKey) { + throw new Error( + `Provider "${runtimeModel.provider}" does not support runtime auth refresh.`, + ); + } + authStorage.setRuntimeApiKey(runtimeModel.provider, preparedAuth.apiKey); + if (preparedAuth.baseUrl) { + runtimeModel = { ...runtimeModel, baseUrl: preparedAuth.baseUrl }; + effectiveModel = { ...effectiveModel, baseUrl: preparedAuth.baseUrl }; + } + runtimeAuthState = { + ...runtimeAuthState, + expiresAt: preparedAuth.expiresAt, + }; + if (preparedAuth.expiresAt) { + const remaining = preparedAuth.expiresAt - Date.now(); + log.debug( + `Runtime auth refreshed for ${runtimeModel.provider}; expires in ${Math.max(0, Math.floor(remaining / 1000))}s.`, + ); + } })() .catch((err) => { - log.warn(`Copilot token refresh failed: ${describeUnknownError(err)}`); + log.warn( + `Runtime auth refresh failed for ${runtimeModel.provider}: ${describeUnknownError(err)}`, + ); throw err; }) .finally(() => { - copilotTokenState.refreshInFlight = undefined; + if (runtimeAuthState) { + runtimeAuthState.refreshInFlight = undefined; + } }); - await copilotTokenState.refreshInFlight; + await runtimeAuthState.refreshInFlight; }; - const scheduleCopilotRefresh = (): void => { - if (!copilotTokenState || copilotRefreshCancelled) { + const scheduleRuntimeAuthRefresh = (): void => { + if (!runtimeAuthState || runtimeAuthRefreshCancelled) { return; } - if (!hasCopilotGithubToken()) { - log.warn("Skipping Copilot refresh scheduling; GitHub token missing."); + if (!hasRefreshableRuntimeAuth()) { + log.warn( + `Skipping runtime auth refresh scheduling for ${runtimeModel.provider}; source credential missing.`, + ); return; } - clearCopilotRefreshTimer(); + if (!runtimeAuthState.expiresAt) { + return; + } + clearRuntimeAuthRefreshTimer(); const now = Date.now(); - const refreshAt = copilotTokenState.expiresAt - COPILOT_REFRESH_MARGIN_MS; - const delayMs = Math.max(COPILOT_REFRESH_MIN_DELAY_MS, refreshAt - now); + const refreshAt = runtimeAuthState.expiresAt - RUNTIME_AUTH_REFRESH_MARGIN_MS; + const delayMs = Math.max(RUNTIME_AUTH_REFRESH_MIN_DELAY_MS, refreshAt - now); const timer = setTimeout(() => { - if (copilotRefreshCancelled) { + if (runtimeAuthRefreshCancelled) { return; } - refreshCopilotToken("scheduled") - .then(() => scheduleCopilotRefresh()) + refreshRuntimeAuth("scheduled") + .then(() => scheduleRuntimeAuthRefresh()) .catch(() => { - if (copilotRefreshCancelled) { + if (runtimeAuthRefreshCancelled) { return; } const retryTimer = setTimeout(() => { - if (copilotRefreshCancelled) { + if (runtimeAuthRefreshCancelled) { return; } - refreshCopilotToken("scheduled-retry") - .then(() => scheduleCopilotRefresh()) + refreshRuntimeAuth("scheduled-retry") + .then(() => scheduleRuntimeAuthRefresh()) .catch(() => undefined); - }, COPILOT_REFRESH_RETRY_MS); - copilotTokenState.refreshTimer = retryTimer; - if (copilotRefreshCancelled) { + }, RUNTIME_AUTH_REFRESH_RETRY_MS); + const activeRuntimeAuthState = runtimeAuthState; + if (activeRuntimeAuthState) { + activeRuntimeAuthState.refreshTimer = retryTimer; + } + if (runtimeAuthRefreshCancelled && activeRuntimeAuthState) { clearTimeout(retryTimer); - copilotTokenState.refreshTimer = undefined; + activeRuntimeAuthState.refreshTimer = undefined; } }); }, delayMs); - copilotTokenState.refreshTimer = timer; - if (copilotRefreshCancelled) { + runtimeAuthState.refreshTimer = timer; + if (runtimeAuthRefreshCancelled) { clearTimeout(timer); - copilotTokenState.refreshTimer = undefined; + runtimeAuthState.refreshTimer = undefined; } }; @@ -599,7 +642,7 @@ export async function runEmbeddedPiAgent( const resolveApiKeyForCandidate = async (candidate?: string) => { return getApiKeyForModel({ - model, + model: runtimeModel, cfg: params.config, profileId: candidate, store: authStore, @@ -613,26 +656,53 @@ export async function runEmbeddedPiAgent( if (!apiKeyInfo.apiKey) { if (apiKeyInfo.mode !== "aws-sdk") { throw new Error( - `No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`, + `No API key resolved for provider "${runtimeModel.provider}" (auth mode: ${apiKeyInfo.mode}).`, ); } lastProfileId = resolvedProfileId; return; } - if (model.provider === "github-copilot") { - const { resolveCopilotApiToken } = - await import("../../providers/github-copilot-token.js"); - const copilotToken = await resolveCopilotApiToken({ - githubToken: apiKeyInfo.apiKey, - }); - authStorage.setRuntimeApiKey(model.provider, copilotToken.token); - if (copilotTokenState) { - copilotTokenState.githubToken = apiKeyInfo.apiKey; - copilotTokenState.expiresAt = copilotToken.expiresAt; - scheduleCopilotRefresh(); + let runtimeAuthHandled = false; + const preparedAuth = await prepareProviderRuntimeAuth({ + provider: runtimeModel.provider, + config: params.config, + workspaceDir: resolvedWorkspace, + env: process.env, + context: { + config: params.config, + agentDir, + workspaceDir: resolvedWorkspace, + env: process.env, + provider: runtimeModel.provider, + modelId, + model: runtimeModel, + apiKey: apiKeyInfo.apiKey, + authMode: apiKeyInfo.mode, + profileId: apiKeyInfo.profileId, + }, + }); + if (preparedAuth?.baseUrl) { + runtimeModel = { ...runtimeModel, baseUrl: preparedAuth.baseUrl }; + effectiveModel = { ...effectiveModel, baseUrl: preparedAuth.baseUrl }; + } + if (preparedAuth?.apiKey) { + authStorage.setRuntimeApiKey(runtimeModel.provider, preparedAuth.apiKey); + runtimeAuthState = { + sourceApiKey: apiKeyInfo.apiKey, + authMode: apiKeyInfo.mode, + profileId: apiKeyInfo.profileId, + expiresAt: preparedAuth.expiresAt, + }; + if (preparedAuth.expiresAt) { + scheduleRuntimeAuthRefresh(); } + runtimeAuthHandled = true; + } + if (runtimeAuthHandled) { + // Plugin-owned runtime auth already stored the exchanged credential. } else { - authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); + authStorage.setRuntimeApiKey(runtimeModel.provider, apiKeyInfo.apiKey); + runtimeAuthState = null; } lastProfileId = apiKeyInfo.profileId; }; @@ -721,11 +791,11 @@ export async function runEmbeddedPiAgent( } } - const maybeRefreshCopilotForAuthError = async ( + const maybeRefreshRuntimeAuthForAuthError = async ( errorText: string, retried: boolean, ): Promise => { - if (!copilotTokenState || retried) { + if (!runtimeAuthState || retried) { return false; } if (!isFailoverErrorMessage(errorText)) { @@ -735,8 +805,8 @@ export async function runEmbeddedPiAgent( return false; } try { - await refreshCopilotToken("auth-error"); - scheduleCopilotRefresh(); + await refreshRuntimeAuth("auth-error"); + scheduleRuntimeAuthRefresh(); return true; } catch { return false; @@ -846,7 +916,7 @@ export async function runEmbeddedPiAgent( }; } runLoopIterations += 1; - const copilotAuthRetry = authRetryPending; + const runtimeAuthRetry = authRetryPending; authRetryPending = false; attemptedThinking.add(thinkLevel); await fs.mkdir(resolvedWorkspace, { recursive: true }); @@ -1233,7 +1303,7 @@ export async function runEmbeddedPiAgent( ? describeFailoverError(normalizedPromptFailover) : describeFailoverError(promptError); const errorText = promptErrorDetails.message || describeUnknownError(promptError); - if (await maybeRefreshCopilotForAuthError(errorText, copilotAuthRetry)) { + if (await maybeRefreshRuntimeAuthForAuthError(errorText, runtimeAuthRetry)) { authRetryPending = true; continue; } @@ -1403,9 +1473,9 @@ export async function runEmbeddedPiAgent( if ( authFailure && - (await maybeRefreshCopilotForAuthError( + (await maybeRefreshRuntimeAuthForAuthError( lastAssistant?.errorMessage ?? "", - copilotAuthRetry, + runtimeAuthRetry, )) ) { authRetryPending = true; @@ -1620,7 +1690,7 @@ export async function runEmbeddedPiAgent( } } finally { await contextEngine.dispose?.(); - stopCopilotRefreshTimer(); + stopRuntimeAuthRefreshTimer(); process.chdir(prevCwd); } }), diff --git a/src/agents/provider-capabilities.test.ts b/src/agents/provider-capabilities.test.ts index ef59f025de8..f2e5d32e70e 100644 --- a/src/agents/provider-capabilities.test.ts +++ b/src/agents/provider-capabilities.test.ts @@ -1,4 +1,31 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; + +const resolveProviderCapabilitiesWithPluginMock = vi.fn((params: { provider: string }) => { + switch (params.provider) { + case "openrouter": + return { + openAiCompatTurnValidation: false, + geminiThoughtSignatureSanitization: true, + geminiThoughtSignatureModelHints: ["gemini"], + }; + case "openai-codex": + return { + providerFamily: "openai", + }; + case "github-copilot": + return { + dropThinkingBlockModelHints: ["claude"], + }; + default: + return undefined; + } +}); + +vi.mock("../plugins/provider-runtime.js", () => ({ + resolveProviderCapabilitiesWithPlugin: (params: { provider: string }) => + resolveProviderCapabilitiesWithPluginMock(params), +})); + import { isAnthropicProviderFamily, isOpenAiProviderFamily, diff --git a/src/agents/provider-capabilities.ts b/src/agents/provider-capabilities.ts index f443fac4d11..4b6022179c8 100644 --- a/src/agents/provider-capabilities.ts +++ b/src/agents/provider-capabilities.ts @@ -1,3 +1,4 @@ +import { resolveProviderCapabilitiesWithPlugin } from "../plugins/provider-runtime.js"; import { normalizeProviderId } from "./model-selection.js"; export type ProviderCapabilities = { @@ -55,14 +56,6 @@ const PROVIDER_CAPABILITIES: Record> = { openai: { providerFamily: "openai", }, - "openai-codex": { - providerFamily: "openai", - }, - openrouter: { - openAiCompatTurnValidation: false, - geminiThoughtSignatureSanitization: true, - geminiThoughtSignatureModelHints: ["gemini"], - }, opencode: { openAiCompatTurnValidation: false, geminiThoughtSignatureSanitization: true, @@ -77,16 +70,17 @@ const PROVIDER_CAPABILITIES: Record> = { geminiThoughtSignatureSanitization: true, geminiThoughtSignatureModelHints: ["gemini"], }, - "github-copilot": { - dropThinkingBlockModelHints: ["claude"], - }, }; export function resolveProviderCapabilities(provider?: string | null): ProviderCapabilities { const normalized = normalizeProviderId(provider ?? ""); + const pluginCapabilities = normalized + ? resolveProviderCapabilitiesWithPlugin({ provider: normalized }) + : undefined; return { ...DEFAULT_PROVIDER_CAPABILITIES, ...PROVIDER_CAPABILITIES[normalized], + ...pluginCapabilities, }; } diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 671071ebc6f..82dac5fd88c 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -2,6 +2,17 @@ export type { AnyAgentTool, OpenClawPluginApi, ProviderDiscoveryContext, + ProviderCatalogContext, + ProviderCatalogResult, + ProviderCacheTtlEligibilityContext, + ProviderPreparedRuntimeAuth, + ProviderPrepareExtraParamsContext, + ProviderPrepareDynamicModelContext, + ProviderPrepareRuntimeAuthContext, + ProviderResolveDynamicModelContext, + ProviderNormalizeResolvedModelContext, + ProviderRuntimeModel, + ProviderWrapStreamFnContext, OpenClawPluginService, ProviderAuthContext, ProviderAuthMethodNonInteractiveContext, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index eaae5d08968..3d6a456b7f3 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -103,6 +103,15 @@ export type { PluginLogger, ProviderAuthContext, ProviderAuthResult, + ProviderCacheTtlEligibilityContext, + ProviderPreparedRuntimeAuth, + ProviderPrepareExtraParamsContext, + ProviderPrepareDynamicModelContext, + ProviderPrepareRuntimeAuthContext, + ProviderResolveDynamicModelContext, + ProviderNormalizeResolvedModelContext, + ProviderRuntimeModel, + ProviderWrapStreamFnContext, } from "../plugins/types.js"; export type { GatewayRequestHandler, @@ -805,7 +814,11 @@ export type { ContextEngineFactory } from "../context-engine/registry.js"; // agentDir/store) rather than importing raw helpers directly. export { requireApiKey } from "../agents/model-auth.js"; export type { ResolvedProviderAuth } from "../agents/model-auth.js"; -export type { ProviderDiscoveryContext } from "../plugins/types.js"; +export type { + ProviderCatalogContext, + ProviderCatalogResult, + ProviderDiscoveryContext, +} from "../plugins/types.js"; export { applyProviderDefaultModel, promptAndConfigureOpenAICompatibleSelfHostedProvider, diff --git a/src/plugin-sdk/minimax-portal-auth.ts b/src/plugin-sdk/minimax-portal-auth.ts index 9a8b0f0bb80..cc41b2cc80d 100644 --- a/src/plugin-sdk/minimax-portal-auth.ts +++ b/src/plugin-sdk/minimax-portal-auth.ts @@ -6,6 +6,7 @@ export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export type { OpenClawPluginApi, ProviderAuthContext, + ProviderCatalogContext, ProviderAuthResult, } from "../plugins/types.js"; export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js"; diff --git a/src/plugin-sdk/qwen-portal-auth.ts b/src/plugin-sdk/qwen-portal-auth.ts index 1056b98d0cf..01533a77e8c 100644 --- a/src/plugin-sdk/qwen-portal-auth.ts +++ b/src/plugin-sdk/qwen-portal-auth.ts @@ -3,5 +3,9 @@ export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; -export type { OpenClawPluginApi, ProviderAuthContext } from "../plugins/types.js"; +export type { + OpenClawPluginApi, + ProviderAuthContext, + ProviderCatalogContext, +} from "../plugins/types.js"; export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js"; diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index b8b89609049..6a0cbbdf988 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -25,7 +25,10 @@ export type NormalizedPluginsConfig = { export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ "device-pair", + "github-copilot", "ollama", + "openai-codex", + "openrouter", "phone-control", "sglang", "talk-voice", diff --git a/src/plugins/provider-discovery.test.ts b/src/plugins/provider-discovery.test.ts index f794c88830c..4952961062b 100644 --- a/src/plugins/provider-discovery.test.ts +++ b/src/plugins/provider-discovery.test.ts @@ -3,6 +3,7 @@ import type { ModelProviderConfig } from "../config/types.js"; import { groupPluginDiscoveryProvidersByOrder, normalizePluginDiscoveryResult, + runProviderCatalog, } from "./provider-discovery.js"; import type { ProviderDiscoveryOrder, ProviderPlugin } from "./types.js"; @@ -10,15 +11,17 @@ function makeProvider(params: { id: string; label?: string; order?: ProviderDiscoveryOrder; + mode?: "catalog" | "discovery"; }): ProviderPlugin { + const hook = { + ...(params.order ? { order: params.order } : {}), + run: async () => null, + }; return { id: params.id, label: params.label ?? params.id, auth: [], - discovery: { - ...(params.order ? { order: params.order } : {}), - run: async () => null, - }, + ...(params.mode === "discovery" ? { discovery: hook } : { catalog: hook }), }; } @@ -45,6 +48,14 @@ describe("groupPluginDiscoveryProvidersByOrder", () => { expect(grouped.paired.map((provider) => provider.id)).toEqual(["paired"]); expect(grouped.late.map((provider) => provider.id)).toEqual(["late-a", "late-b"]); }); + + it("uses the legacy discovery hook when catalog is absent", () => { + const grouped = groupPluginDiscoveryProvidersByOrder([ + makeProvider({ id: "legacy", label: "Legacy", order: "profile", mode: "discovery" }), + ]); + + expect(grouped.profile.map((provider) => provider.id)).toEqual(["legacy"]); + }); }); describe("normalizePluginDiscoveryResult", () => { @@ -88,3 +99,34 @@ describe("normalizePluginDiscoveryResult", () => { }); }); }); + +describe("runProviderCatalog", () => { + it("prefers catalog over discovery when both exist", async () => { + const catalogRun = async () => ({ + provider: makeModelProviderConfig({ baseUrl: "http://catalog.example/v1" }), + }); + const discoveryRun = async () => ({ + provider: makeModelProviderConfig({ baseUrl: "http://discovery.example/v1" }), + }); + + const result = await runProviderCatalog({ + provider: { + id: "demo", + label: "Demo", + auth: [], + catalog: { run: catalogRun }, + discovery: { run: discoveryRun }, + }, + config: {}, + env: {}, + resolveProviderApiKey: () => ({ apiKey: undefined }), + }); + + expect(result).toEqual({ + provider: { + baseUrl: "http://catalog.example/v1", + models: [], + }, + }); + }); +}); diff --git a/src/plugins/provider-discovery.ts b/src/plugins/provider-discovery.ts index 6e94f3f6d30..ccecd889fa3 100644 --- a/src/plugins/provider-discovery.ts +++ b/src/plugins/provider-discovery.ts @@ -6,12 +6,16 @@ import type { ProviderDiscoveryOrder, ProviderPlugin } from "./types.js"; const DISCOVERY_ORDER: readonly ProviderDiscoveryOrder[] = ["simple", "profile", "paired", "late"]; +function resolveProviderCatalogHook(provider: ProviderPlugin) { + return provider.catalog ?? provider.discovery; +} + export function resolvePluginDiscoveryProviders(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; }): ProviderPlugin[] { - return resolvePluginProviders(params).filter((provider) => provider.discovery); + return resolvePluginProviders(params).filter((provider) => resolveProviderCatalogHook(provider)); } export function groupPluginDiscoveryProvidersByOrder( @@ -25,7 +29,7 @@ export function groupPluginDiscoveryProvidersByOrder( } as Record; for (const provider of providers) { - const order = provider.discovery?.order ?? "late"; + const order = resolveProviderCatalogHook(provider)?.order ?? "late"; grouped[order].push(provider); } @@ -63,3 +67,23 @@ export function normalizePluginDiscoveryResult(params: { } return normalized; } + +export function runProviderCatalog(params: { + provider: ProviderPlugin; + config: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + resolveProviderApiKey: (providerId?: string) => { + apiKey: string | undefined; + discoveryApiKey?: string; + }; +}) { + return resolveProviderCatalogHook(params.provider)?.run({ + config: params.config, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + env: params.env, + resolveProviderApiKey: params.resolveProviderApiKey, + }); +} diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts new file mode 100644 index 00000000000..9db3ef3e002 --- /dev/null +++ b/src/plugins/provider-runtime.test.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ProviderPlugin, ProviderRuntimeModel } from "./types.js"; + +const resolvePluginProvidersMock = vi.fn((_: unknown) => [] as ProviderPlugin[]); + +vi.mock("./providers.js", () => ({ + resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never), +})); + +import { + prepareProviderExtraParams, + resolveProviderCacheTtlEligibility, + resolveProviderCapabilitiesWithPlugin, + normalizeProviderResolvedModelWithPlugin, + prepareProviderDynamicModel, + prepareProviderRuntimeAuth, + resolveProviderRuntimePlugin, + runProviderDynamicModel, + wrapProviderStreamFn, +} from "./provider-runtime.js"; + +const MODEL: ProviderRuntimeModel = { + id: "demo-model", + name: "Demo Model", + api: "openai-responses", + provider: "demo", + baseUrl: "https://api.example.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, +}; + +describe("provider-runtime", () => { + beforeEach(() => { + resolvePluginProvidersMock.mockReset(); + resolvePluginProvidersMock.mockReturnValue([]); + }); + + it("matches providers by alias for runtime hook lookup", () => { + resolvePluginProvidersMock.mockReturnValue([ + { + id: "openrouter", + label: "OpenRouter", + aliases: ["Open Router"], + auth: [], + }, + ]); + + const plugin = resolveProviderRuntimePlugin({ provider: "Open Router" }); + + expect(plugin?.id).toBe("openrouter"); + }); + + it("dispatches runtime hooks for the matched provider", async () => { + const prepareDynamicModel = vi.fn(async () => undefined); + const prepareRuntimeAuth = vi.fn(async () => ({ + apiKey: "runtime-token", + baseUrl: "https://runtime.example.com/v1", + expiresAt: 123, + })); + resolvePluginProvidersMock.mockReturnValue([ + { + id: "demo", + label: "Demo", + auth: [], + resolveDynamicModel: () => MODEL, + prepareDynamicModel, + capabilities: { + providerFamily: "openai", + }, + prepareExtraParams: ({ extraParams }) => ({ + ...extraParams, + transport: "auto", + }), + wrapStreamFn: ({ streamFn }) => streamFn, + normalizeResolvedModel: ({ model }) => ({ + ...model, + api: "openai-codex-responses", + }), + prepareRuntimeAuth, + isCacheTtlEligible: ({ modelId }) => modelId.startsWith("anthropic/"), + }, + ]); + + expect( + runProviderDynamicModel({ + provider: "demo", + context: { + provider: "demo", + modelId: MODEL.id, + modelRegistry: { find: () => null } as never, + }, + }), + ).toMatchObject(MODEL); + + await prepareProviderDynamicModel({ + provider: "demo", + context: { + provider: "demo", + modelId: MODEL.id, + modelRegistry: { find: () => null } as never, + }, + }); + + expect( + resolveProviderCapabilitiesWithPlugin({ + provider: "demo", + }), + ).toMatchObject({ + providerFamily: "openai", + }); + + expect( + prepareProviderExtraParams({ + provider: "demo", + context: { + provider: "demo", + modelId: MODEL.id, + extraParams: { temperature: 0.3 }, + }, + }), + ).toMatchObject({ + temperature: 0.3, + transport: "auto", + }); + + expect( + wrapProviderStreamFn({ + provider: "demo", + context: { + provider: "demo", + modelId: MODEL.id, + streamFn: vi.fn(), + }, + }), + ).toBeTypeOf("function"); + + expect( + normalizeProviderResolvedModelWithPlugin({ + provider: "demo", + context: { + provider: "demo", + modelId: MODEL.id, + model: MODEL, + }, + }), + ).toMatchObject({ + ...MODEL, + api: "openai-codex-responses", + }); + + await expect( + prepareProviderRuntimeAuth({ + provider: "demo", + env: process.env, + context: { + env: process.env, + provider: "demo", + modelId: MODEL.id, + model: MODEL, + apiKey: "source-token", + authMode: "api-key", + }, + }), + ).resolves.toMatchObject({ + apiKey: "runtime-token", + baseUrl: "https://runtime.example.com/v1", + expiresAt: 123, + }); + + expect( + resolveProviderCacheTtlEligibility({ + provider: "demo", + context: { + provider: "demo", + modelId: "anthropic/claude-sonnet-4-5", + }, + }), + ).toBe(true); + + expect(prepareDynamicModel).toHaveBeenCalledTimes(1); + expect(prepareRuntimeAuth).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts new file mode 100644 index 00000000000..ca44f33a8ba --- /dev/null +++ b/src/plugins/provider-runtime.ts @@ -0,0 +1,123 @@ +import { normalizeProviderId } from "../agents/model-selection.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolvePluginProviders } from "./providers.js"; +import type { + ProviderCacheTtlEligibilityContext, + ProviderPrepareExtraParamsContext, + ProviderPrepareDynamicModelContext, + ProviderPrepareRuntimeAuthContext, + ProviderPlugin, + ProviderResolveDynamicModelContext, + ProviderRuntimeModel, + ProviderWrapStreamFnContext, +} from "./types.js"; + +function matchesProviderId(provider: ProviderPlugin, providerId: string): boolean { + const normalized = normalizeProviderId(providerId); + if (!normalized) { + return false; + } + if (normalizeProviderId(provider.id) === normalized) { + return true; + } + return (provider.aliases ?? []).some((alias) => normalizeProviderId(alias) === normalized); +} + +export function resolveProviderRuntimePlugin(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): ProviderPlugin | undefined { + return resolvePluginProviders(params).find((plugin) => + matchesProviderId(plugin, params.provider), + ); +} + +export function runProviderDynamicModel(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderResolveDynamicModelContext; +}): ProviderRuntimeModel | undefined { + return resolveProviderRuntimePlugin(params)?.resolveDynamicModel?.(params.context) ?? undefined; +} + +export async function prepareProviderDynamicModel(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderPrepareDynamicModelContext; +}): Promise { + await resolveProviderRuntimePlugin(params)?.prepareDynamicModel?.(params.context); +} + +export function normalizeProviderResolvedModelWithPlugin(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: { + config?: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + provider: string; + modelId: string; + model: ProviderRuntimeModel; + }; +}): ProviderRuntimeModel | undefined { + return ( + resolveProviderRuntimePlugin(params)?.normalizeResolvedModel?.(params.context) ?? undefined + ); +} + +export function resolveProviderCapabilitiesWithPlugin(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}) { + return resolveProviderRuntimePlugin(params)?.capabilities; +} + +export function prepareProviderExtraParams(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderPrepareExtraParamsContext; +}) { + return resolveProviderRuntimePlugin(params)?.prepareExtraParams?.(params.context) ?? undefined; +} + +export function wrapProviderStreamFn(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderWrapStreamFnContext; +}) { + return resolveProviderRuntimePlugin(params)?.wrapStreamFn?.(params.context) ?? undefined; +} + +export async function prepareProviderRuntimeAuth(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderPrepareRuntimeAuthContext; +}) { + return await resolveProviderRuntimePlugin(params)?.prepareRuntimeAuth?.(params.context); +} + +export function resolveProviderCacheTtlEligibility(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderCacheTtlEligibilityContext; +}) { + return resolveProviderRuntimePlugin(params)?.isCacheTtlEligible?.(params.context); +} diff --git a/src/plugins/provider-validation.test.ts b/src/plugins/provider-validation.test.ts index e37f1d38163..fc91a74576d 100644 --- a/src/plugins/provider-validation.test.ts +++ b/src/plugins/provider-validation.test.ts @@ -124,4 +124,33 @@ describe("normalizeRegisteredProvider", () => { 'provider "demo" model-picker metadata ignored because it has no auth methods', ]); }); + + it("prefers catalog when a provider registers both catalog and discovery", () => { + const { diagnostics, pushDiagnostic } = collectDiagnostics(); + + const provider = normalizeRegisteredProvider({ + pluginId: "demo-plugin", + source: "/tmp/demo/index.ts", + provider: makeProvider({ + catalog: { + run: async () => null, + }, + discovery: { + run: async () => ({ + provider: { + baseUrl: "http://127.0.0.1:8000/v1", + models: [], + }, + }), + }, + }), + pushDiagnostic, + }); + + expect(provider?.catalog).toBeDefined(); + expect(provider?.discovery).toBeUndefined(); + expect(diagnostics.map((diag) => diag.message)).toEqual([ + 'provider "demo" registered both catalog and discovery; using catalog', + ]); + }); }); diff --git a/src/plugins/provider-validation.ts b/src/plugins/provider-validation.ts index ae7c807ed99..5401144929c 100644 --- a/src/plugins/provider-validation.ts +++ b/src/plugins/provider-validation.ts @@ -212,11 +212,24 @@ export function normalizeRegisteredProvider(params: { wizard: params.provider.wizard, pushDiagnostic: params.pushDiagnostic, }); + const catalog = params.provider.catalog; + const discovery = params.provider.discovery; + if (catalog && discovery) { + pushProviderDiagnostic({ + level: "warn", + pluginId: params.pluginId, + source: params.source, + message: `provider "${id}" registered both catalog and discovery; using catalog`, + pushDiagnostic: params.pushDiagnostic, + }); + } const { wizard: _ignoredWizard, docsPath: _ignoredDocsPath, aliases: _ignoredAliases, envVars: _ignoredEnvVars, + catalog: _ignoredCatalog, + discovery: _ignoredDiscovery, ...restProvider } = params.provider; return { @@ -227,6 +240,8 @@ export function normalizeRegisteredProvider(params: { ...(aliases ? { aliases } : {}), ...(envVars ? { envVars } : {}), auth, + ...(catalog ? { catalog } : {}), + ...(!catalog && discovery ? { discovery } : {}), ...(wizard ? { wizard } : {}), }; } diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 40e3de13529..404974f4fc1 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1,12 +1,17 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import type { Api, Model } from "@mariozechner/pi-ai"; +import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; import type { Command } from "commander"; import type { ApiKeyCredential, AuthProfileCredential, OAuthCredential, } from "../agents/auth-profiles/types.js"; +import type { ProviderCapabilities } from "../agents/provider-capabilities.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; +import type { ThinkLevel } from "../auto-reply/thinking.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import type { ChannelDock } from "../channels/dock.js"; import type { ChannelId, ChannelPlugin } from "../channels/plugins/types.js"; @@ -166,9 +171,9 @@ export type ProviderAuthMethod = { ) => Promise; }; -export type ProviderDiscoveryOrder = "simple" | "profile" | "paired" | "late"; +export type ProviderCatalogOrder = "simple" | "profile" | "paired" | "late"; -export type ProviderDiscoveryContext = { +export type ProviderCatalogContext = { config: OpenClawConfig; agentDir?: string; workspaceDir?: string; @@ -179,17 +184,168 @@ export type ProviderDiscoveryContext = { }; }; -export type ProviderDiscoveryResult = +export type ProviderCatalogResult = | { provider: ModelProviderConfig } | { providers: Record } | null | undefined; -export type ProviderPluginDiscovery = { - order?: ProviderDiscoveryOrder; - run: (ctx: ProviderDiscoveryContext) => Promise; +export type ProviderPluginCatalog = { + order?: ProviderCatalogOrder; + run: (ctx: ProviderCatalogContext) => Promise; }; +/** + * Fully-resolved runtime model shape used by the embedded runner. + * + * Catalog hooks publish config-time `models.providers` entries. + * Runtime hooks below operate on the final `pi-ai` model object after + * discovery/override merging, just before inference runs. + */ +export type ProviderRuntimeModel = Model; + +export type ProviderRuntimeProviderConfig = { + baseUrl?: string; + api?: ModelProviderConfig["api"]; + models?: ModelProviderConfig["models"]; + headers?: unknown; +}; + +/** + * Sync hook for provider-owned model ids that are not present in the local + * registry/catalog yet. + * + * Use this for pass-through providers or provider-specific forward-compat + * behavior. The hook should be cheap and side-effect free; async refreshes + * belong in `prepareDynamicModel`. + */ +export type ProviderResolveDynamicModelContext = { + config?: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + provider: string; + modelId: string; + modelRegistry: ModelRegistry; + providerConfig?: ProviderRuntimeProviderConfig; +}; + +/** + * Optional async warm-up for dynamic model resolution. + * + * Called only from async model resolution paths, before retrying + * `resolveDynamicModel`. This is the place to refresh caches or fetch provider + * metadata over the network. + */ +export type ProviderPrepareDynamicModelContext = ProviderResolveDynamicModelContext; + +/** + * Last-chance rewrite hook for provider-owned transport normalization. + * + * Runs after OpenClaw resolves an explicit/discovered/dynamic model and before + * the embedded runner uses it. Typical uses: swap API ids, fix base URLs, or + * patch provider-specific compat bits. + */ +export type ProviderNormalizeResolvedModelContext = { + config?: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + provider: string; + modelId: string; + model: ProviderRuntimeModel; +}; + +/** + * Runtime auth input for providers that need an extra exchange step before + * inference. The incoming `apiKey` is the raw credential resolved from auth + * profiles/env/config. The returned value should be the actual token/key to use + * for the request. + */ +export type ProviderPrepareRuntimeAuthContext = { + config?: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + provider: string; + modelId: string; + model: ProviderRuntimeModel; + apiKey: string; + authMode: string; + profileId?: string; +}; + +/** + * Result of `prepareRuntimeAuth`. + * + * `apiKey` is required and becomes the runtime credential stored in auth + * storage. `baseUrl` is optional and lets providers like GitHub Copilot swap to + * an entitlement-specific endpoint at request time. `expiresAt` enables generic + * background refresh in long-running turns. + */ +export type ProviderPreparedRuntimeAuth = { + apiKey: string; + baseUrl?: string; + expiresAt?: number; +}; + +/** + * Provider-owned extra-param normalization before OpenClaw builds its generic + * stream option wrapper. + * + * Use this to set provider defaults or rewrite provider-specific config keys + * into the merged `extraParams` object. Return the full next extraParams object. + */ +export type ProviderPrepareExtraParamsContext = { + config?: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + provider: string; + modelId: string; + extraParams?: Record; + thinkingLevel?: ThinkLevel; +}; + +/** + * Provider-owned stream wrapper hook after OpenClaw applies its generic + * transport-independent wrappers. + * + * Use this for provider-specific payload/header/model mutations that still run + * through the normal `pi-ai` stream path. + */ +export type ProviderWrapStreamFnContext = ProviderPrepareExtraParamsContext & { + streamFn?: StreamFn; +}; + +/** + * Provider-owned prompt-cache eligibility. + * + * Return `true` or `false` to override OpenClaw's built-in provider cache TTL + * detection for this provider. Return `undefined` to fall back to core rules. + */ +export type ProviderCacheTtlEligibilityContext = { + provider: string; + modelId: string; +}; + +/** + * @deprecated Use ProviderCatalogOrder. + */ +export type ProviderDiscoveryOrder = ProviderCatalogOrder; + +/** + * @deprecated Use ProviderCatalogContext. + */ +export type ProviderDiscoveryContext = ProviderCatalogContext; + +/** + * @deprecated Use ProviderCatalogResult. + */ +export type ProviderDiscoveryResult = ProviderCatalogResult; + +/** + * @deprecated Use ProviderPluginCatalog. + */ +export type ProviderPluginDiscovery = ProviderPluginCatalog; + export type ProviderPluginWizardOnboarding = { choiceId?: string; choiceLabel?: string; @@ -227,7 +383,93 @@ export type ProviderPlugin = { aliases?: string[]; envVars?: string[]; auth: ProviderAuthMethod[]; + /** + * Preferred hook for plugin-defined provider catalogs. + * Returns provider config/model definitions that merge into models.providers. + */ + catalog?: ProviderPluginCatalog; + /** + * Legacy alias for catalog. + * Kept for compatibility with existing provider plugins. + */ discovery?: ProviderPluginDiscovery; + /** + * Sync runtime fallback for model ids not present in the local catalog. + * + * Hook order: + * 1. discovered/static model lookup + * 2. plugin `resolveDynamicModel` + * 3. core fallback heuristics + * 4. generic provider-config fallback + * + * Keep this hook cheap and deterministic. If you need network I/O first, use + * `prepareDynamicModel` to prime state for the async retry path. + */ + resolveDynamicModel?: ( + ctx: ProviderResolveDynamicModelContext, + ) => ProviderRuntimeModel | null | undefined; + /** + * Optional async prefetch for dynamic model resolution. + * + * OpenClaw calls this only from async model resolution paths. After it + * completes, `resolveDynamicModel` is called again. + */ + prepareDynamicModel?: (ctx: ProviderPrepareDynamicModelContext) => Promise; + /** + * Provider-owned transport normalization. + * + * Use this to rewrite a resolved model without forking the generic runner: + * swap API ids, update base URLs, or adjust compat flags for a provider's + * transport quirks. + */ + normalizeResolvedModel?: ( + ctx: ProviderNormalizeResolvedModelContext, + ) => ProviderRuntimeModel | null | undefined; + /** + * Static provider capability overrides consumed by shared transcript/tooling + * logic. + * + * Use this when the provider behaves like OpenAI/Anthropic, needs transcript + * sanitization quirks, or requires provider-family hints. + */ + capabilities?: Partial; + /** + * Provider-owned extra-param normalization before generic stream option + * wrapping. + * + * Typical uses: set provider-default `transport`, map provider-specific + * config aliases, or inject extra request metadata sourced from + * `agents.defaults.models./.params`. + */ + prepareExtraParams?: ( + ctx: ProviderPrepareExtraParamsContext, + ) => Record | null | undefined; + /** + * Provider-owned stream wrapper applied after generic OpenClaw wrappers. + * + * Typical uses: provider attribution headers, request-body rewrites, or + * provider-specific compat payload patches that do not justify a separate + * transport implementation. + */ + wrapStreamFn?: (ctx: ProviderWrapStreamFnContext) => StreamFn | null | undefined; + /** + * Runtime auth exchange hook. + * + * Called after OpenClaw resolves the raw configured credential but before the + * runner stores it in runtime auth storage. This lets plugins exchange a + * source credential (for example a GitHub token) into a short-lived runtime + * token plus optional base URL override. + */ + prepareRuntimeAuth?: ( + ctx: ProviderPrepareRuntimeAuthContext, + ) => Promise; + /** + * Provider-owned cache TTL eligibility. + * + * Use this when a proxy provider supports Anthropic-style prompt caching for + * only a subset of upstream models. + */ + isCacheTtlEligible?: (ctx: ProviderCacheTtlEligibilityContext) => boolean | undefined; wizard?: ProviderPluginWizard; formatApiKey?: (cred: AuthProfileCredential) => string; refreshOAuth?: (cred: OAuthCredential) => Promise; From 392ddb56e22ca89bd2bd072c27e89842b53296e3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 15:18:06 -0700 Subject: [PATCH 031/943] build(plugins): add bundled provider plugin manifests --- extensions/github-copilot/openclaw.plugin.json | 9 +++++++++ extensions/github-copilot/package.json | 12 ++++++++++++ extensions/openai-codex/openclaw.plugin.json | 9 +++++++++ extensions/openai-codex/package.json | 12 ++++++++++++ extensions/openrouter/openclaw.plugin.json | 9 +++++++++ extensions/openrouter/package.json | 12 ++++++++++++ 6 files changed, 63 insertions(+) create mode 100644 extensions/github-copilot/openclaw.plugin.json create mode 100644 extensions/github-copilot/package.json create mode 100644 extensions/openai-codex/openclaw.plugin.json create mode 100644 extensions/openai-codex/package.json create mode 100644 extensions/openrouter/openclaw.plugin.json create mode 100644 extensions/openrouter/package.json diff --git a/extensions/github-copilot/openclaw.plugin.json b/extensions/github-copilot/openclaw.plugin.json new file mode 100644 index 00000000000..ec3f8690eee --- /dev/null +++ b/extensions/github-copilot/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "github-copilot", + "providers": ["github-copilot"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/github-copilot/package.json b/extensions/github-copilot/package.json new file mode 100644 index 00000000000..45140022168 --- /dev/null +++ b/extensions/github-copilot/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/github-copilot-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw GitHub Copilot provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/openai-codex/openclaw.plugin.json b/extensions/openai-codex/openclaw.plugin.json new file mode 100644 index 00000000000..0dfd4106a9a --- /dev/null +++ b/extensions/openai-codex/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "openai-codex", + "providers": ["openai-codex"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/openai-codex/package.json b/extensions/openai-codex/package.json new file mode 100644 index 00000000000..49730240ff8 --- /dev/null +++ b/extensions/openai-codex/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/openai-codex-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw OpenAI Codex provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/openrouter/openclaw.plugin.json b/extensions/openrouter/openclaw.plugin.json new file mode 100644 index 00000000000..7e7840cb1c9 --- /dev/null +++ b/extensions/openrouter/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "openrouter", + "providers": ["openrouter"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/openrouter/package.json b/extensions/openrouter/package.json new file mode 100644 index 00000000000..243569356f5 --- /dev/null +++ b/extensions/openrouter/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/openrouter-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw OpenRouter provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} From 8b001d6e4dcfe62dd9f000b3b50448789e0ee150 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 15:58:31 -0700 Subject: [PATCH 032/943] Channels: move onboarding adapters into extensions --- extensions/discord/src/onboarding.ts | 2 +- .../imessage/src/onboarding.ts | 27 ++++++------ .../signal/src/onboarding.ts | 42 ++++++++++++------- .../slack/src/onboarding.ts | 37 ++++++++-------- extensions/telegram/src/onboarding.ts | 2 +- src/channels/plugins/onboarding/discord.ts | 2 - .../plugins/onboarding/imessage.test.ts | 2 +- .../plugins/onboarding/signal.test.ts | 5 ++- src/channels/plugins/onboarding/telegram.ts | 1 - src/channels/plugins/onboarding/whatsapp.ts | 2 - src/channels/plugins/plugins-channel.test.ts | 2 +- src/commands/onboarding/registry.ts | 12 +++--- src/plugin-sdk/discord.ts | 3 +- src/plugin-sdk/imessage.ts | 3 +- src/plugin-sdk/index.ts | 10 ++--- src/plugin-sdk/signal.ts | 3 +- src/plugin-sdk/slack.ts | 3 +- src/plugin-sdk/telegram.ts | 3 +- 18 files changed, 90 insertions(+), 71 deletions(-) rename src/channels/plugins/onboarding/imessage.ts => extensions/imessage/src/onboarding.ts (91%) rename src/channels/plugins/onboarding/signal.ts => extensions/signal/src/onboarding.ts (84%) rename src/channels/plugins/onboarding/slack.ts => extensions/slack/src/onboarding.ts (92%) delete mode 100644 src/channels/plugins/onboarding/discord.ts delete mode 100644 src/channels/plugins/onboarding/telegram.ts delete mode 100644 src/channels/plugins/onboarding/whatsapp.ts diff --git a/extensions/discord/src/onboarding.ts b/extensions/discord/src/onboarding.ts index f4883b1254f..061f4614241 100644 --- a/extensions/discord/src/onboarding.ts +++ b/extensions/discord/src/onboarding.ts @@ -5,9 +5,9 @@ import type { import { configureChannelAccessWithAllowlist } from "../../../src/channels/plugins/onboarding/channel-access-configure.js"; import { applySingleTokenPromptResult, - parseMentionOrPrefixedId, noteChannelLookupFailure, noteChannelLookupSummary, + parseMentionOrPrefixedId, patchChannelConfigForAccount, promptLegacyChannelAllowFrom, resolveAccountIdForConfigure, diff --git a/src/channels/plugins/onboarding/imessage.ts b/extensions/imessage/src/onboarding.ts similarity index 91% rename from src/channels/plugins/onboarding/imessage.ts rename to extensions/imessage/src/onboarding.ts index b4941ebd82e..85b3dc43be4 100644 --- a/src/channels/plugins/onboarding/imessage.ts +++ b/extensions/imessage/src/onboarding.ts @@ -1,14 +1,7 @@ -import { - listIMessageAccountIds, - resolveDefaultIMessageAccountId, - resolveIMessageAccount, -} from "../../../../extensions/imessage/src/accounts.js"; -import { normalizeIMessageHandle } from "../../../../extensions/imessage/src/targets.js"; -import { detectBinary } from "../../../commands/onboard-helpers.js"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { formatDocsLink } from "../../../terminal/links.js"; -import type { WizardPrompter } from "../../../wizard/prompts.js"; -import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; +import type { + ChannelOnboardingAdapter, + ChannelOnboardingDmPolicy, +} from "../../../src/channels/plugins/onboarding-types.js"; import { parseOnboardingEntriesAllowingWildcard, patchChannelConfigForAccount, @@ -16,7 +9,17 @@ import { resolveAccountIdForConfigure, setChannelDmPolicyWithAllowFrom, setOnboardingChannelEnabled, -} from "./helpers.js"; +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { detectBinary } from "../../../src/commands/onboard-helpers.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { + listIMessageAccountIds, + resolveDefaultIMessageAccountId, + resolveIMessageAccount, +} from "./accounts.js"; +import { normalizeIMessageHandle } from "./targets.js"; const channel = "imessage" as const; diff --git a/src/channels/plugins/onboarding/signal.ts b/extensions/signal/src/onboarding.ts similarity index 84% rename from src/channels/plugins/onboarding/signal.ts rename to extensions/signal/src/onboarding.ts index 6609d4bbd76..7279ea1977a 100644 --- a/src/channels/plugins/onboarding/signal.ts +++ b/extensions/signal/src/onboarding.ts @@ -1,17 +1,27 @@ +import type { + ChannelOnboardingAdapter, + ChannelOnboardingDmPolicy, +} from "../../../src/channels/plugins/onboarding-types.js"; +import { + parseOnboardingEntriesAllowingWildcard, + patchChannelConfigForAccount, + promptParsedAllowFromForScopedChannel, + resolveAccountIdForConfigure, + setChannelDmPolicyWithAllowFrom, + setOnboardingChannelEnabled, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { formatCliCommand } from "../../../src/cli/command-format.js"; +import { detectBinary } from "../../../src/commands/onboard-helpers.js"; +import { installSignalCli } from "../../../src/commands/signal-install.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { normalizeE164 } from "../../../src/utils.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { listSignalAccountIds, resolveDefaultSignalAccountId, resolveSignalAccount, -} from "../../../../extensions/signal/src/accounts.js"; -import { formatCliCommand } from "../../../cli/command-format.js"; -import { detectBinary } from "../../../commands/onboard-helpers.js"; -import { installSignalCli } from "../../../commands/signal-install.js"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { formatDocsLink } from "../../../terminal/links.js"; -import { normalizeE164 } from "../../../utils.js"; -import type { WizardPrompter } from "../../../wizard/prompts.js"; -import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; -import * as onboardingHelpers from "./helpers.js"; +} from "./accounts.js"; const channel = "signal" as const; const MIN_E164_DIGITS = 5; @@ -41,7 +51,7 @@ function isUuidLike(value: string): boolean { } export function parseSignalAllowFromEntries(raw: string): { entries: string[]; error?: string } { - return onboardingHelpers.parseOnboardingEntriesAllowingWildcard(raw, (entry) => { + return parseOnboardingEntriesAllowingWildcard(raw, (entry) => { if (entry.toLowerCase().startsWith("uuid:")) { const id = entry.slice("uuid:".length).trim(); if (!id) { @@ -65,7 +75,7 @@ async function promptSignalAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - return onboardingHelpers.promptParsedAllowFromForScopedChannel({ + return promptParsedAllowFromForScopedChannel({ cfg: params.cfg, channel: "signal", accountId: params.accountId, @@ -97,7 +107,7 @@ const dmPolicy: ChannelOnboardingDmPolicy = { allowFromKey: "channels.signal.allowFrom", getCurrent: (cfg) => cfg.channels?.signal?.dmPolicy ?? "pairing", setPolicy: (cfg, policy) => - onboardingHelpers.setChannelDmPolicyWithAllowFrom({ + setChannelDmPolicyWithAllowFrom({ cfg, channel: "signal", dmPolicy: policy, @@ -133,7 +143,7 @@ export const signalOnboardingAdapter: ChannelOnboardingAdapter = { options, }) => { const defaultSignalAccountId = resolveDefaultSignalAccountId(cfg); - const signalAccountId = await onboardingHelpers.resolveAccountIdForConfigure({ + const signalAccountId = await resolveAccountIdForConfigure({ cfg, prompter, label: "Signal", @@ -216,7 +226,7 @@ export const signalOnboardingAdapter: ChannelOnboardingAdapter = { } if (account) { - next = onboardingHelpers.patchChannelConfigForAccount({ + next = patchChannelConfigForAccount({ cfg: next, channel: "signal", accountId: signalAccountId, @@ -240,5 +250,5 @@ export const signalOnboardingAdapter: ChannelOnboardingAdapter = { return { cfg: next, accountId: signalAccountId }; }, dmPolicy, - disable: (cfg) => onboardingHelpers.setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), }; diff --git a/src/channels/plugins/onboarding/slack.ts b/extensions/slack/src/onboarding.ts similarity index 92% rename from src/channels/plugins/onboarding/slack.ts rename to extensions/slack/src/onboarding.ts index 8b956edcd23..552c8a9d19b 100644 --- a/src/channels/plugins/onboarding/slack.ts +++ b/extensions/slack/src/onboarding.ts @@ -1,22 +1,12 @@ -import { inspectSlackAccount } from "../../../../extensions/slack/src/account-inspect.js"; +import type { + ChannelOnboardingAdapter, + ChannelOnboardingDmPolicy, +} from "../../../src/channels/plugins/onboarding-types.js"; +import { configureChannelAccessWithAllowlist } from "../../../src/channels/plugins/onboarding/channel-access-configure.js"; import { - listSlackAccountIds, - resolveDefaultSlackAccountId, - resolveSlackAccount, -} from "../../../../extensions/slack/src/accounts.js"; -import { resolveSlackChannelAllowlist } from "../../../../extensions/slack/src/resolve-channels.js"; -import { resolveSlackUserAllowlist } from "../../../../extensions/slack/src/resolve-users.js"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { hasConfiguredSecretInput } from "../../../config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; -import { formatDocsLink } from "../../../terminal/links.js"; -import type { WizardPrompter } from "../../../wizard/prompts.js"; -import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; -import { configureChannelAccessWithAllowlist } from "./channel-access-configure.js"; -import { - parseMentionOrPrefixedId, noteChannelLookupFailure, noteChannelLookupSummary, + parseMentionOrPrefixedId, patchChannelConfigForAccount, promptLegacyChannelAllowFrom, resolveAccountIdForConfigure, @@ -25,7 +15,20 @@ import { setAccountGroupPolicyForChannel, setLegacyChannelDmPolicyWithAllowFrom, setOnboardingChannelEnabled, -} from "./helpers.js"; +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { inspectSlackAccount } from "./account-inspect.js"; +import { + listSlackAccountIds, + resolveDefaultSlackAccountId, + resolveSlackAccount, +} from "./accounts.js"; +import { resolveSlackChannelAllowlist } from "./resolve-channels.js"; +import { resolveSlackUserAllowlist } from "./resolve-users.js"; const channel = "slack" as const; diff --git a/extensions/telegram/src/onboarding.ts b/extensions/telegram/src/onboarding.ts index c555b748d2d..f5911e304ed 100644 --- a/extensions/telegram/src/onboarding.ts +++ b/extensions/telegram/src/onboarding.ts @@ -5,8 +5,8 @@ import type { import { applySingleTokenPromptResult, patchChannelConfigForAccount, - promptSingleChannelSecretInput, promptResolvedAllowFrom, + promptSingleChannelSecretInput, resolveAccountIdForConfigure, resolveOnboardingAccountId, setChannelDmPolicyWithAllowFrom, diff --git a/src/channels/plugins/onboarding/discord.ts b/src/channels/plugins/onboarding/discord.ts deleted file mode 100644 index 34fd42d3b98..00000000000 --- a/src/channels/plugins/onboarding/discord.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Shim: re-exports from extension -export * from "../../../../extensions/discord/src/onboarding.js"; diff --git a/src/channels/plugins/onboarding/imessage.test.ts b/src/channels/plugins/onboarding/imessage.test.ts index 266408a612b..6825cdc67e0 100644 --- a/src/channels/plugins/onboarding/imessage.test.ts +++ b/src/channels/plugins/onboarding/imessage.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseIMessageAllowFromEntries } from "./imessage.js"; +import { parseIMessageAllowFromEntries } from "../../../../extensions/imessage/src/onboarding.js"; describe("parseIMessageAllowFromEntries", () => { it("parses handles and chat targets", () => { diff --git a/src/channels/plugins/onboarding/signal.test.ts b/src/channels/plugins/onboarding/signal.test.ts index 920b68f3149..e0b83003db7 100644 --- a/src/channels/plugins/onboarding/signal.test.ts +++ b/src/channels/plugins/onboarding/signal.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { normalizeSignalAccountInput, parseSignalAllowFromEntries } from "./signal.js"; +import { + normalizeSignalAccountInput, + parseSignalAllowFromEntries, +} from "../../../../extensions/signal/src/onboarding.js"; describe("normalizeSignalAccountInput", () => { it("normalizes valid E.164 numbers", () => { diff --git a/src/channels/plugins/onboarding/telegram.ts b/src/channels/plugins/onboarding/telegram.ts deleted file mode 100644 index 772f7d1ce71..00000000000 --- a/src/channels/plugins/onboarding/telegram.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../../../extensions/telegram/src/onboarding.js"; diff --git a/src/channels/plugins/onboarding/whatsapp.ts b/src/channels/plugins/onboarding/whatsapp.ts deleted file mode 100644 index e2694f8d7c5..00000000000 --- a/src/channels/plugins/onboarding/whatsapp.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Shim: re-exports from extensions/whatsapp/src/onboarding.ts -export * from "../../../../extensions/whatsapp/src/onboarding.js"; diff --git a/src/channels/plugins/plugins-channel.test.ts b/src/channels/plugins/plugins-channel.test.ts index 37fea7e032d..76452137682 100644 --- a/src/channels/plugins/plugins-channel.test.ts +++ b/src/channels/plugins/plugins-channel.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from "vitest"; +import { normalizeSignalAccountInput } from "../../../extensions/signal/src/onboarding.js"; import type { OpenClawConfig } from "../../config/config.js"; import { normalizeIMessageMessagingTarget } from "./normalize/imessage.js"; import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize/signal.js"; -import { normalizeSignalAccountInput } from "./onboarding/signal.js"; import { telegramOutbound } from "./outbound/telegram.js"; import { whatsappOutbound } from "./outbound/whatsapp.js"; diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts index 814eab75ea2..cd660350911 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/onboarding/registry.ts @@ -1,10 +1,10 @@ +import { discordOnboardingAdapter } from "../../../extensions/discord/src/onboarding.js"; +import { imessageOnboardingAdapter } from "../../../extensions/imessage/src/onboarding.js"; +import { signalOnboardingAdapter } from "../../../extensions/signal/src/onboarding.js"; +import { slackOnboardingAdapter } from "../../../extensions/slack/src/onboarding.js"; +import { telegramOnboardingAdapter } from "../../../extensions/telegram/src/onboarding.js"; +import { whatsappOnboardingAdapter } from "../../../extensions/whatsapp/src/onboarding.js"; import { listChannelPlugins } from "../../channels/plugins/index.js"; -import { discordOnboardingAdapter } from "../../channels/plugins/onboarding/discord.js"; -import { imessageOnboardingAdapter } from "../../channels/plugins/onboarding/imessage.js"; -import { signalOnboardingAdapter } from "../../channels/plugins/onboarding/signal.js"; -import { slackOnboardingAdapter } from "../../channels/plugins/onboarding/slack.js"; -import { telegramOnboardingAdapter } from "../../channels/plugins/onboarding/telegram.js"; -import { whatsappOnboardingAdapter } from "../../channels/plugins/onboarding/whatsapp.js"; import type { ChannelChoice } from "../onboard-types.js"; import type { ChannelOnboardingAdapter } from "./types.js"; diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index 5b4897f46e9..4a84e48a743 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -1,5 +1,6 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; +export type { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js"; export type { InspectedDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; export type { ResolvedDiscordAccount } from "../../extensions/discord/src/accounts.js"; export * from "./channel-plugin-common.js"; @@ -34,7 +35,7 @@ export { resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { discordOnboardingAdapter } from "../channels/plugins/onboarding/discord.js"; +export { discordOnboardingAdapter } from "../../extensions/discord/src/onboarding.js"; export { DiscordConfigSchema } from "../config/zod-schema.providers-core.js"; export { diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index 4c3160e95cb..1e231babc58 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -1,4 +1,5 @@ export type { ResolvedIMessageAccount } from "../../extensions/imessage/src/accounts.js"; +export type { IMessageAccountConfig } from "../config/types.js"; export * from "./channel-plugin-common.js"; export { listIMessageAccountIds, @@ -23,7 +24,7 @@ export { resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { imessageOnboardingAdapter } from "../channels/plugins/onboarding/imessage.js"; +export { imessageOnboardingAdapter } from "../../extensions/imessage/src/onboarding.js"; export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 3d6a456b7f3..8b4a4f28a4e 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -664,7 +664,7 @@ export { export { inspectDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; export type { InspectedDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; export { collectDiscordAuditChannelIds } from "../../extensions/discord/src/audit.js"; -export { discordOnboardingAdapter } from "../channels/plugins/onboarding/discord.js"; +export { discordOnboardingAdapter } from "../../extensions/discord/src/onboarding.js"; export { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget, @@ -679,7 +679,7 @@ export { resolveIMessageAccount, type ResolvedIMessageAccount, } from "../../extensions/imessage/src/accounts.js"; -export { imessageOnboardingAdapter } from "../channels/plugins/onboarding/imessage.js"; +export { imessageOnboardingAdapter } from "../../extensions/imessage/src/onboarding.js"; export { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, @@ -713,7 +713,7 @@ export { extractSlackToolSend, listSlackMessageActions, } from "../../extensions/slack/src/message-actions.js"; -export { slackOnboardingAdapter } from "../channels/plugins/onboarding/slack.js"; +export { slackOnboardingAdapter } from "../../extensions/slack/src/onboarding.js"; export { looksLikeSlackTargetId, normalizeSlackMessagingTarget, @@ -729,7 +729,7 @@ export { } from "../../extensions/telegram/src/accounts.js"; export { inspectTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; export type { InspectedTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; -export { telegramOnboardingAdapter } from "../channels/plugins/onboarding/telegram.js"; +export { telegramOnboardingAdapter } from "../../extensions/telegram/src/onboarding.js"; export { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget, @@ -748,7 +748,7 @@ export { resolveSignalAccount, type ResolvedSignalAccount, } from "../../extensions/signal/src/accounts.js"; -export { signalOnboardingAdapter } from "../channels/plugins/onboarding/signal.js"; +export { signalOnboardingAdapter } from "../../extensions/signal/src/onboarding.js"; export { looksLikeSignalTargetId, normalizeSignalMessagingTarget, diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index d8be4ddc9e4..7a44633b8e6 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -1,5 +1,6 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; export type { ResolvedSignalAccount } from "../../extensions/signal/src/accounts.js"; +export type { SignalAccountConfig } from "../config/types.js"; export * from "./channel-plugin-common.js"; export { listSignalAccountIds, @@ -15,7 +16,7 @@ export { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, } from "../config/runtime-group-policy.js"; -export { signalOnboardingAdapter } from "../channels/plugins/onboarding/signal.js"; +export { signalOnboardingAdapter } from "../../extensions/signal/src/onboarding.js"; export { SignalConfigSchema } from "../config/zod-schema.providers-core.js"; export { normalizeE164 } from "../utils.js"; diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 740a0fabef0..c05d9786d5c 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -1,4 +1,5 @@ export type { OpenClawConfig } from "../config/config.js"; +export type { SlackAccountConfig } from "../config/types.slack.js"; export type { InspectedSlackAccount } from "../../extensions/slack/src/account-inspect.js"; export type { ResolvedSlackAccount } from "../../extensions/slack/src/accounts.js"; export * from "./channel-plugin-common.js"; @@ -38,7 +39,7 @@ export { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { slackOnboardingAdapter } from "../channels/plugins/onboarding/slack.js"; +export { slackOnboardingAdapter } from "../../extensions/slack/src/onboarding.js"; export { SlackConfigSchema } from "../config/zod-schema.providers-core.js"; export { handleSlackMessageAction } from "./slack-message-actions.js"; diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index d816ca4125d..f9d8d0ed723 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -7,6 +7,7 @@ export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; +export type { TelegramAccountConfig, TelegramActionConfig } from "../config/types.js"; export type { InspectedTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; export type { ResolvedTelegramAccount } from "../../extensions/telegram/src/accounts.js"; export type { TelegramProbe } from "../../extensions/telegram/src/probe.js"; @@ -63,7 +64,7 @@ export { resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { telegramOnboardingAdapter } from "../channels/plugins/onboarding/telegram.js"; +export { telegramOnboardingAdapter } from "../../extensions/telegram/src/onboarding.js"; export { TelegramConfigSchema } from "../config/zod-schema.providers-core.js"; export { buildTokenChannelStatusSummary } from "./status-helpers.js"; From 4eee827dce6bb86e7f0c39a474da5d0aab517266 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 15:58:48 -0700 Subject: [PATCH 033/943] Channels: use owned helper imports --- extensions/discord/src/account-inspect.ts | 9 ++++++--- extensions/discord/src/accounts.ts | 7 +++++-- extensions/imessage/src/accounts.ts | 3 +-- extensions/signal/src/accounts.ts | 3 +-- extensions/slack/src/account-inspect.ts | 9 ++++++--- extensions/telegram/src/account-inspect.ts | 2 +- extensions/telegram/src/accounts.ts | 2 +- extensions/whatsapp/src/accounts.ts | 8 ++++++-- src/plugin-sdk/whatsapp.ts | 1 + 9 files changed, 28 insertions(+), 16 deletions(-) diff --git a/extensions/discord/src/account-inspect.ts b/extensions/discord/src/account-inspect.ts index d99f87aeb56..bddea792c14 100644 --- a/extensions/discord/src/account-inspect.ts +++ b/extensions/discord/src/account-inspect.ts @@ -1,10 +1,13 @@ -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { DiscordAccountConfig } from "../../../src/config/types.discord.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + type OpenClawConfig, + type DiscordAccountConfig, +} from "openclaw/plugin-sdk/discord"; import { hasConfiguredSecretInput, normalizeSecretInputString, } from "../../../src/config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId, diff --git a/extensions/discord/src/accounts.ts b/extensions/discord/src/accounts.ts index 6cd1699f192..a623e97446f 100644 --- a/extensions/discord/src/accounts.ts +++ b/extensions/discord/src/accounts.ts @@ -1,7 +1,10 @@ +import type { + OpenClawConfig, + DiscordAccountConfig, + DiscordActionConfig, +} from "openclaw/plugin-sdk/discord"; import { createAccountActionGate } from "../../../src/channels/plugins/account-action-gate.js"; import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { DiscordAccountConfig, DiscordActionConfig } from "../../../src/config/types.js"; import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; import { normalizeAccountId } from "../../../src/routing/session-key.js"; import { resolveDiscordToken } from "./token.js"; diff --git a/extensions/imessage/src/accounts.ts b/extensions/imessage/src/accounts.ts index f370fd54860..1a6ca8bceb9 100644 --- a/extensions/imessage/src/accounts.ts +++ b/extensions/imessage/src/accounts.ts @@ -1,8 +1,7 @@ +import { normalizeAccountId, type IMessageAccountConfig } from "openclaw/plugin-sdk/imessage"; import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { IMessageAccountConfig } from "../../../src/config/types.js"; import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; -import { normalizeAccountId } from "../../../src/routing/session-key.js"; export type ResolvedIMessageAccount = { accountId: string; diff --git a/extensions/signal/src/accounts.ts b/extensions/signal/src/accounts.ts index edcfa4c1d64..38316955edd 100644 --- a/extensions/signal/src/accounts.ts +++ b/extensions/signal/src/accounts.ts @@ -1,8 +1,7 @@ +import { normalizeAccountId, type SignalAccountConfig } from "openclaw/plugin-sdk/signal"; import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { SignalAccountConfig } from "../../../src/config/types.js"; import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; -import { normalizeAccountId } from "../../../src/routing/session-key.js"; export type ResolvedSignalAccount = { accountId: string; diff --git a/extensions/slack/src/account-inspect.ts b/extensions/slack/src/account-inspect.ts index 85fde407cbb..8ada00e9832 100644 --- a/extensions/slack/src/account-inspect.ts +++ b/extensions/slack/src/account-inspect.ts @@ -1,10 +1,13 @@ -import type { OpenClawConfig } from "../../../src/config/config.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + type OpenClawConfig, + type SlackAccountConfig, +} from "openclaw/plugin-sdk/slack"; import { hasConfiguredSecretInput, normalizeSecretInputString, } from "../../../src/config/types.secrets.js"; -import type { SlackAccountConfig } from "../../../src/config/types.slack.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; import { mergeSlackAccountConfig, diff --git a/extensions/telegram/src/account-inspect.ts b/extensions/telegram/src/account-inspect.ts index 8014df80080..6aca9122b43 100644 --- a/extensions/telegram/src/account-inspect.ts +++ b/extensions/telegram/src/account-inspect.ts @@ -1,10 +1,10 @@ +import type { TelegramAccountConfig } from "openclaw/plugin-sdk/telegram"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { coerceSecretRef, hasConfiguredSecretInput, normalizeSecretInputString, } from "../../../src/config/types.secrets.js"; -import type { TelegramAccountConfig } from "../../../src/config/types.telegram.js"; import { tryReadSecretFileSync } from "../../../src/infra/secret-file.js"; import { resolveAccountWithDefaultFallback } from "../../../src/plugin-sdk/account-resolution.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; diff --git a/extensions/telegram/src/accounts.ts b/extensions/telegram/src/accounts.ts index 71d78590488..cff6853a5b1 100644 --- a/extensions/telegram/src/accounts.ts +++ b/extensions/telegram/src/accounts.ts @@ -1,7 +1,7 @@ import util from "node:util"; +import type { TelegramAccountConfig, TelegramActionConfig } from "openclaw/plugin-sdk/telegram"; import { createAccountActionGate } from "../../../src/channels/plugins/account-action-gate.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { TelegramAccountConfig, TelegramActionConfig } from "../../../src/config/types.js"; import { isTruthyEnvValue } from "../../../src/infra/env.js"; import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; import { diff --git a/extensions/whatsapp/src/accounts.ts b/extensions/whatsapp/src/accounts.ts index a225b09dfb8..53e73128894 100644 --- a/extensions/whatsapp/src/accounts.ts +++ b/extensions/whatsapp/src/accounts.ts @@ -1,9 +1,13 @@ import fs from "node:fs"; import path from "node:path"; +import type { + DmPolicy, + GroupPolicy, + OpenClawConfig, + WhatsAppAccountConfig, +} from "openclaw/plugin-sdk/whatsapp"; import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; import { resolveOAuthDir } from "../../../src/config/paths.js"; -import type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "../../../src/config/types.js"; import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; import { resolveUserPath } from "../../../src/utils.js"; diff --git a/src/plugin-sdk/whatsapp.ts b/src/plugin-sdk/whatsapp.ts index 4ea4fa8d2de..e84a60e785c 100644 --- a/src/plugin-sdk/whatsapp.ts +++ b/src/plugin-sdk/whatsapp.ts @@ -1,6 +1,7 @@ export type { ChannelMessageActionName } from "../channels/plugins/types.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; +export type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "../config/types.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; From aa1454d1a80c35417bc047e78c8cd85ecfecb33c Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Sun, 15 Mar 2026 19:06:11 -0400 Subject: [PATCH 034/943] Plugins: broaden plugin surface for Codex App Server (#45318) * Plugins: add inbound claim and Telegram interaction seams * Plugins: add Discord interaction surface * Chore: fix formatting after plugin rebase * fix(hooks): preserve observers after inbound claim * test(hooks): cover claimed inbound observer delivery * fix(plugins): harden typing lease refreshes * fix(discord): pass real auth to plugin interactions * fix(plugins): remove raw session binding runtime exposure * fix(plugins): tighten interactive callback handling * Plugins: gate conversation binding with approvals * Plugins: migrate legacy plugin binding records * Plugins/phone-control: update test command context * Plugins: migrate legacy binding ids * Plugins: migrate legacy codex session bindings * Discord: fix plugin interaction handling * Discord: support direct plugin conversation binds * Plugins: preserve Discord command bind targets * Tests: fix plugin binding and interactive fallout * Discord: stabilize directory lookup tests * Discord: route bound DMs to plugins * Discord: restore plugin bindings after restart * Telegram: persist detached plugin bindings * Plugins: limit binding APIs to Telegram and Discord * Plugins: harden bound conversation routing * Plugins: fix extension target imports * Plugins: fix Telegram runtime extension imports * Plugins: format rebased binding handlers * Discord: bind group DM interactions by channel --------- Co-authored-by: Vincent Koc --- extensions/discord/src/components.test.ts | 9 +- extensions/discord/src/components.ts | 27 + extensions/discord/src/directory-live.test.ts | 91 +- .../discord/src/monitor/agent-components.ts | 258 +++++- .../monitor/message-handler.preflight.test.ts | 87 ++ .../src/monitor/message-handler.preflight.ts | 10 +- .../discord/src/monitor/monitor.test.ts | 252 ++++++ .../discord/src/monitor/native-command.ts | 38 +- .../monitor/thread-bindings.discord-api.ts | 2 +- .../monitor/thread-bindings.lifecycle.test.ts | 93 ++ .../src/monitor/thread-bindings.lifecycle.ts | 7 +- .../src/monitor/thread-bindings.manager.ts | 24 +- .../src/monitor/thread-bindings.state.ts | 3 + .../src/monitor/thread-bindings.types.ts | 2 + extensions/discord/src/send.ts | 1 + extensions/discord/src/send.typing.ts | 9 + extensions/discord/src/targets.test.ts | 17 +- extensions/lobster/src/lobster-tool.test.ts | 1 + extensions/phone-control/index.test.ts | 6 + extensions/telegram/src/bot-handlers.ts | 88 ++ extensions/telegram/src/bot.test.ts | 63 +- extensions/telegram/src/conversation-route.ts | 25 +- extensions/telegram/src/send.test-harness.ts | 4 + extensions/telegram/src/send.test.ts | 42 + extensions/telegram/src/send.ts | 103 +++ .../telegram/src/thread-bindings.test.ts | 36 + extensions/telegram/src/thread-bindings.ts | 17 +- extensions/test-utils/plugin-api.ts | 1 + .../reply/dispatch-from-config.test.ts | 507 ++++++++++- src/auto-reply/reply/dispatch-from-config.ts | 187 +++- src/hooks/message-hook-mappers.test.ts | 48 + src/hooks/message-hook-mappers.ts | 132 +++ src/plugin-sdk/index.ts | 14 + src/plugins/commands.test.ts | 104 +++ src/plugins/commands.ts | 125 ++- src/plugins/conversation-binding.test.ts | 575 ++++++++++++ src/plugins/conversation-binding.ts | 825 ++++++++++++++++++ src/plugins/hooks.test-helpers.ts | 21 + src/plugins/hooks.ts | 200 +++++ src/plugins/interactive.test.ts | 201 +++++ src/plugins/interactive.ts | 366 ++++++++ src/plugins/loader.ts | 6 + src/plugins/registry.ts | 47 +- src/plugins/runtime/runtime-channel.ts | 78 +- .../runtime/runtime-discord-typing.test.ts | 57 ++ src/plugins/runtime/runtime-discord-typing.ts | 62 ++ .../runtime/runtime-telegram-typing.test.ts | 83 ++ .../runtime/runtime-telegram-typing.ts | 60 ++ src/plugins/runtime/types-channel.ts | 54 ++ src/plugins/services.test.ts | 11 +- src/plugins/services.ts | 6 +- src/plugins/types.ts | 185 ++++ src/plugins/wired-hooks-inbound-claim.test.ts | 175 ++++ 53 files changed, 5322 insertions(+), 123 deletions(-) create mode 100644 extensions/discord/src/send.typing.ts create mode 100644 src/plugins/conversation-binding.test.ts create mode 100644 src/plugins/conversation-binding.ts create mode 100644 src/plugins/interactive.test.ts create mode 100644 src/plugins/interactive.ts create mode 100644 src/plugins/runtime/runtime-discord-typing.test.ts create mode 100644 src/plugins/runtime/runtime-discord-typing.ts create mode 100644 src/plugins/runtime/runtime-telegram-typing.test.ts create mode 100644 src/plugins/runtime/runtime-telegram-typing.ts create mode 100644 src/plugins/wired-hooks-inbound-claim.test.ts diff --git a/extensions/discord/src/components.test.ts b/extensions/discord/src/components.test.ts index 9a49af7b469..44350b4fc4b 100644 --- a/extensions/discord/src/components.test.ts +++ b/extensions/discord/src/components.test.ts @@ -19,11 +19,13 @@ describe("discord components", () => { blocks: [ { type: "actions", - buttons: [{ label: "Approve", style: "success" }], + buttons: [{ label: "Approve", style: "success", callbackData: "codex:approve" }], }, ], modal: { title: "Details", + callbackData: "codex:modal", + allowedUsers: ["discord:user-1"], fields: [{ type: "text", label: "Requester" }], }, }); @@ -39,6 +41,11 @@ describe("discord components", () => { const trigger = result.entries.find((entry) => entry.kind === "modal-trigger"); expect(trigger?.modalId).toBe(result.modals[0]?.id); + expect(result.entries.find((entry) => entry.kind === "button")?.callbackData).toBe( + "codex:approve", + ); + expect(result.modals[0]?.callbackData).toBe("codex:modal"); + expect(result.modals[0]?.allowedUsers).toEqual(["discord:user-1"]); }); it("requires options for modal select fields", () => { diff --git a/extensions/discord/src/components.ts b/extensions/discord/src/components.ts index 2052c5baf69..272da58170a 100644 --- a/extensions/discord/src/components.ts +++ b/extensions/discord/src/components.ts @@ -46,6 +46,7 @@ export type DiscordComponentButtonSpec = { label: string; style?: DiscordComponentButtonStyle; url?: string; + callbackData?: string; emoji?: { name: string; id?: string; @@ -70,10 +71,12 @@ export type DiscordComponentSelectOption = { export type DiscordComponentSelectSpec = { type?: DiscordComponentSelectType; + callbackData?: string; placeholder?: string; minValues?: number; maxValues?: number; options?: DiscordComponentSelectOption[]; + allowedUsers?: string[]; }; export type DiscordComponentSectionAccessory = @@ -136,8 +139,10 @@ export type DiscordModalFieldSpec = { export type DiscordModalSpec = { title: string; + callbackData?: string; triggerLabel?: string; triggerStyle?: DiscordComponentButtonStyle; + allowedUsers?: string[]; fields: DiscordModalFieldSpec[]; }; @@ -156,6 +161,7 @@ export type DiscordComponentEntry = { id: string; kind: "button" | "select" | "modal-trigger"; label: string; + callbackData?: string; selectType?: DiscordComponentSelectType; options?: Array<{ value: string; label: string }>; modalId?: string; @@ -188,6 +194,7 @@ export type DiscordModalFieldDefinition = { export type DiscordModalEntry = { id: string; title: string; + callbackData?: string; fields: DiscordModalFieldDefinition[]; sessionKey?: string; agentId?: string; @@ -196,6 +203,7 @@ export type DiscordModalEntry = { messageId?: string; createdAt?: number; expiresAt?: number; + allowedUsers?: string[]; }; export type DiscordComponentBuildResult = { @@ -364,6 +372,7 @@ function parseButtonSpec(raw: unknown, label: string): DiscordComponentButtonSpe label: readString(obj.label, `${label}.label`), style, url, + callbackData: readOptionalString(obj.callbackData), emoji: typeof obj.emoji === "object" && obj.emoji && !Array.isArray(obj.emoji) ? { @@ -395,10 +404,12 @@ function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpe } return { type, + callbackData: readOptionalString(obj.callbackData), placeholder: readOptionalString(obj.placeholder), minValues: readOptionalNumber(obj.minValues), maxValues: readOptionalNumber(obj.maxValues), options: parseSelectOptions(obj.options, `${label}.options`), + allowedUsers: readOptionalStringArray(obj.allowedUsers, `${label}.allowedUsers`), }; } @@ -578,8 +589,10 @@ export function readDiscordComponentSpec(raw: unknown): DiscordComponentMessageS ); modal = { title: readString(modalObj.title, "components.modal.title"), + callbackData: readOptionalString(modalObj.callbackData), triggerLabel: readOptionalString(modalObj.triggerLabel), triggerStyle: readOptionalString(modalObj.triggerStyle) as DiscordComponentButtonStyle, + allowedUsers: readOptionalStringArray(modalObj.allowedUsers, "components.modal.allowedUsers"), fields, }; } @@ -718,6 +731,7 @@ function createButtonComponent(params: { id: componentId, kind: params.modalId ? "modal-trigger" : "button", label: params.spec.label, + callbackData: params.spec.callbackData, modalId: params.modalId, allowedUsers: params.spec.allowedUsers, }, @@ -758,8 +772,10 @@ function createSelectComponent(params: { id: componentId, kind: "select", label: params.spec.placeholder ?? "select", + callbackData: params.spec.callbackData, selectType: "string", options: options.map((option) => ({ value: option.value, label: option.label })), + allowedUsers: params.spec.allowedUsers, }, }; } @@ -777,7 +793,9 @@ function createSelectComponent(params: { id: componentId, kind: "select", label: params.spec.placeholder ?? "user select", + callbackData: params.spec.callbackData, selectType: "user", + allowedUsers: params.spec.allowedUsers, }, }; } @@ -795,7 +813,9 @@ function createSelectComponent(params: { id: componentId, kind: "select", label: params.spec.placeholder ?? "role select", + callbackData: params.spec.callbackData, selectType: "role", + allowedUsers: params.spec.allowedUsers, }, }; } @@ -813,7 +833,9 @@ function createSelectComponent(params: { id: componentId, kind: "select", label: params.spec.placeholder ?? "mentionable select", + callbackData: params.spec.callbackData, selectType: "mentionable", + allowedUsers: params.spec.allowedUsers, }, }; } @@ -830,7 +852,9 @@ function createSelectComponent(params: { id: componentId, kind: "select", label: params.spec.placeholder ?? "channel select", + callbackData: params.spec.callbackData, selectType: "channel", + allowedUsers: params.spec.allowedUsers, }, }; } @@ -1047,16 +1071,19 @@ export function buildDiscordComponentMessage(params: { modals.push({ id: modalId, title: params.spec.modal.title, + callbackData: params.spec.modal.callbackData, fields, sessionKey: params.sessionKey, agentId: params.agentId, accountId: params.accountId, reusable: params.spec.reusable, + allowedUsers: params.spec.modal.allowedUsers, }); const triggerSpec: DiscordComponentButtonSpec = { label: params.spec.modal.triggerLabel ?? "Open form", style: params.spec.modal.triggerStyle ?? "primary", + allowedUsers: params.spec.modal.allowedUsers, }; const { component, entry } = createButtonComponent({ diff --git a/extensions/discord/src/directory-live.test.ts b/extensions/discord/src/directory-live.test.ts index 8ba3bc52c4a..afc0fd94170 100644 --- a/extensions/discord/src/directory-live.test.ts +++ b/extensions/discord/src/directory-live.test.ts @@ -1,74 +1,72 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js"; - -const mocks = vi.hoisted(() => ({ - fetchDiscord: vi.fn(), - normalizeDiscordToken: vi.fn((token: string) => token.trim()), - resolveDiscordAccount: vi.fn(), -})); - -vi.mock("./accounts.js", () => ({ - resolveDiscordAccount: mocks.resolveDiscordAccount, -})); - -vi.mock("./api.js", () => ({ - fetchDiscord: mocks.fetchDiscord, -})); - -vi.mock("./token.js", () => ({ - normalizeDiscordToken: mocks.normalizeDiscordToken, -})); - +import type { OpenClawConfig } from "../../../src/config/config.js"; import { listDiscordDirectoryGroupsLive, listDiscordDirectoryPeersLive } from "./directory-live.js"; function makeParams(overrides: Partial = {}): DirectoryConfigParams { return { - cfg: {} as DirectoryConfigParams["cfg"], + cfg: { + channels: { + discord: { + token: "test-token", + }, + }, + } as OpenClawConfig, + accountId: "default", ...overrides, }; } +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + describe("discord directory live lookups", () => { beforeEach(() => { - vi.clearAllMocks(); - mocks.resolveDiscordAccount.mockReturnValue({ token: "test-token" }); - mocks.normalizeDiscordToken.mockImplementation((token: string) => token.trim()); + vi.restoreAllMocks(); }); it("returns empty group directory when token is missing", async () => { - mocks.normalizeDiscordToken.mockReturnValue(""); - - const rows = await listDiscordDirectoryGroupsLive(makeParams({ query: "general" })); + const rows = await listDiscordDirectoryGroupsLive({ + ...makeParams(), + cfg: { channels: { discord: { token: "" } } } as OpenClawConfig, + query: "general", + }); expect(rows).toEqual([]); - expect(mocks.fetchDiscord).not.toHaveBeenCalled(); }); it("returns empty peer directory without query and skips guild listing", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const rows = await listDiscordDirectoryPeersLive(makeParams({ query: " " })); expect(rows).toEqual([]); - expect(mocks.fetchDiscord).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); }); it("filters group channels by query and respects limit", async () => { - mocks.fetchDiscord.mockImplementation(async (path: string) => { - if (path === "/users/@me/guilds") { - return [ + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url.endsWith("/users/@me/guilds")) { + return jsonResponse([ { id: "g1", name: "Guild 1" }, { id: "g2", name: "Guild 2" }, - ]; + ]); } - if (path === "/guilds/g1/channels") { - return [ + if (url.endsWith("/guilds/g1/channels")) { + return jsonResponse([ { id: "c1", name: "general" }, { id: "c2", name: "random" }, - ]; + ]); } - if (path === "/guilds/g2/channels") { - return [{ id: "c3", name: "announcements" }]; + if (url.endsWith("/guilds/g2/channels")) { + return jsonResponse([{ id: "c3", name: "announcements" }]); } - return []; + return jsonResponse([]); }); const rows = await listDiscordDirectoryGroupsLive(makeParams({ query: "an", limit: 2 })); @@ -80,21 +78,22 @@ describe("discord directory live lookups", () => { }); it("returns ranked peer results and caps member search by limit", async () => { - mocks.fetchDiscord.mockImplementation(async (path: string) => { - if (path === "/users/@me/guilds") { - return [{ id: "g1", name: "Guild 1" }]; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url.endsWith("/users/@me/guilds")) { + return jsonResponse([{ id: "g1", name: "Guild 1" }]); } - if (path.startsWith("/guilds/g1/members/search?")) { - const params = new URLSearchParams(path.split("?")[1] ?? ""); + if (url.includes("/guilds/g1/members/search?")) { + const params = new URL(url).searchParams; expect(params.get("query")).toBe("alice"); expect(params.get("limit")).toBe("2"); - return [ + return jsonResponse([ { user: { id: "u1", username: "alice", bot: false }, nick: "Ali" }, { user: { id: "u2", username: "alice-bot", bot: true }, nick: null }, { user: { id: "u3", username: "ignored", bot: false }, nick: null }, - ]; + ]); } - return []; + return jsonResponse([]); }); const rows = await listDiscordDirectoryPeersLive(makeParams({ query: "alice", limit: 2 })); diff --git a/extensions/discord/src/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts index e954c372bb1..e28bd17b70e 100644 --- a/extensions/discord/src/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -13,6 +13,7 @@ import { type ModalInteraction, type RoleSelectMenuInteraction, type StringSelectMenuInteraction, + type TopLevelComponents, type UserSelectMenuInteraction, } from "@buape/carbon"; import type { APIStringSelectComponent } from "discord-api-types/v10"; @@ -40,6 +41,12 @@ import { logDebug, logError } from "../../../../src/logger.js"; import { getAgentScopedMediaLocalRoots } from "../../../../src/media/local-roots.js"; import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js"; import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.js"; +import { + buildPluginBindingResolvedText, + parsePluginBindingApprovalCustomId, + resolvePluginConversationBindingApproval, +} from "../../../../src/plugins/conversation-binding.js"; +import { dispatchPluginInteractiveHandler } from "../../../../src/plugins/interactive.js"; import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; import { createNonExitingRuntime, type RuntimeEnv } from "../../../../src/runtime.js"; import { @@ -771,6 +778,159 @@ function formatModalSubmissionText( return lines.join("\n"); } +function resolveDiscordInteractionId(interaction: AgentComponentInteraction): string { + const rawId = + interaction.rawData && typeof interaction.rawData === "object" && "id" in interaction.rawData + ? (interaction.rawData as { id?: unknown }).id + : undefined; + if (typeof rawId === "string" && rawId.trim()) { + return rawId.trim(); + } + if (typeof rawId === "number" && Number.isFinite(rawId)) { + return String(rawId); + } + return `discord-interaction:${Date.now()}`; +} + +async function dispatchPluginDiscordInteractiveEvent(params: { + ctx: AgentComponentContext; + interaction: AgentComponentInteraction; + interactionCtx: ComponentInteractionContext; + channelCtx: DiscordChannelContext; + isAuthorizedSender: boolean; + data: string; + kind: "button" | "select" | "modal"; + values?: string[]; + fields?: Array<{ id: string; name: string; values: string[] }>; + messageId?: string; +}): Promise<"handled" | "unmatched"> { + const normalizedConversationId = + params.interactionCtx.rawGuildId || params.channelCtx.channelType === ChannelType.GroupDM + ? `channel:${params.interactionCtx.channelId}` + : `user:${params.interactionCtx.userId}`; + let responded = false; + const respond = { + acknowledge: async () => { + responded = true; + await params.interaction.acknowledge(); + }, + reply: async ({ text, ephemeral = true }: { text: string; ephemeral?: boolean }) => { + responded = true; + await params.interaction.reply({ + content: text, + ephemeral, + }); + }, + followUp: async ({ text, ephemeral = true }: { text: string; ephemeral?: boolean }) => { + responded = true; + await params.interaction.followUp({ + content: text, + ephemeral, + }); + }, + editMessage: async ({ + text, + components, + }: { + text?: string; + components?: TopLevelComponents[]; + }) => { + if (!("update" in params.interaction) || typeof params.interaction.update !== "function") { + throw new Error("Discord interaction cannot update the source message"); + } + responded = true; + await params.interaction.update({ + ...(text !== undefined ? { content: text } : {}), + ...(components !== undefined ? { components } : {}), + }); + }, + clearComponents: async (input?: { text?: string }) => { + if (!("update" in params.interaction) || typeof params.interaction.update !== "function") { + throw new Error("Discord interaction cannot clear components on the source message"); + } + responded = true; + await params.interaction.update({ + ...(input?.text !== undefined ? { content: input.text } : {}), + components: [], + }); + }, + }; + const pluginBindingApproval = parsePluginBindingApprovalCustomId(params.data); + if (pluginBindingApproval) { + const resolved = await resolvePluginConversationBindingApproval({ + approvalId: pluginBindingApproval.approvalId, + decision: pluginBindingApproval.decision, + senderId: params.interactionCtx.userId, + }); + let cleared = false; + try { + await respond.clearComponents(); + cleared = true; + } catch { + try { + await respond.acknowledge(); + } catch { + // Interaction may already be acknowledged; continue with best-effort follow-up. + } + } + try { + await respond.followUp({ + text: buildPluginBindingResolvedText(resolved), + ephemeral: true, + }); + } catch (err) { + logError(`discord plugin binding approval: failed to follow up: ${String(err)}`); + if (!cleared) { + try { + await respond.reply({ + text: buildPluginBindingResolvedText(resolved), + ephemeral: true, + }); + } catch { + // Interaction may no longer accept a direct reply. + } + } + } + return "handled"; + } + const dispatched = await dispatchPluginInteractiveHandler({ + channel: "discord", + data: params.data, + interactionId: resolveDiscordInteractionId(params.interaction), + ctx: { + accountId: params.ctx.accountId, + interactionId: resolveDiscordInteractionId(params.interaction), + conversationId: normalizedConversationId, + parentConversationId: params.channelCtx.parentId, + guildId: params.interactionCtx.rawGuildId, + senderId: params.interactionCtx.userId, + senderUsername: params.interactionCtx.username, + auth: { isAuthorizedSender: params.isAuthorizedSender }, + interaction: { + kind: params.kind, + messageId: params.messageId, + values: params.values, + fields: params.fields, + }, + }, + respond, + }); + if (!dispatched.matched) { + return "unmatched"; + } + if (dispatched.handled) { + if (!responded) { + try { + await respond.acknowledge(); + } catch { + // Interaction may have expired after the handler finished. + } + } + return "handled"; + } + return "unmatched"; +} + function resolveComponentCommandAuthorized(params: { ctx: AgentComponentContext; interactionCtx: ComponentInteractionContext; @@ -1102,6 +1262,17 @@ async function handleDiscordComponentEvent(params: { guildEntries: params.ctx.guildEntries, }); const channelCtx = resolveDiscordChannelContext(params.interaction); + const allowNameMatching = isDangerousNameMatchingEnabled(params.ctx.discordConfig); + const channelConfig = resolveDiscordChannelConfigWithFallback({ + guildInfo, + channelId, + channelName: channelCtx.channelName, + channelSlug: channelCtx.channelSlug, + parentId: channelCtx.parentId, + parentName: channelCtx.parentName, + parentSlug: channelCtx.parentSlug, + scope: channelCtx.isThread ? "thread" : "channel", + }); const unauthorizedReply = `You are not authorized to use this ${params.componentLabel}.`; const memberAllowed = await ensureGuildComponentMemberAllowed({ interaction: params.interaction, @@ -1114,7 +1285,7 @@ async function handleDiscordComponentEvent(params: { replyOpts, componentLabel: params.componentLabel, unauthorizedReply, - allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig), + allowNameMatching, }); if (!memberAllowed) { return; @@ -1127,11 +1298,18 @@ async function handleDiscordComponentEvent(params: { replyOpts, componentLabel: params.componentLabel, unauthorizedReply, - allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig), + allowNameMatching, }); if (!componentAllowed) { return; } + const commandAuthorized = resolveComponentCommandAuthorized({ + ctx: params.ctx, + interactionCtx, + channelConfig, + guildInfo, + allowNameMatching, + }); const consumed = resolveDiscordComponentEntry({ id: parsed.componentId, @@ -1162,6 +1340,22 @@ async function handleDiscordComponentEvent(params: { } const values = params.values ? mapSelectValues(consumed, params.values) : undefined; + if (consumed.callbackData) { + const pluginDispatch = await dispatchPluginDiscordInteractiveEvent({ + ctx: params.ctx, + interaction: params.interaction, + interactionCtx, + channelCtx, + isAuthorizedSender: commandAuthorized, + data: consumed.callbackData, + kind: consumed.kind === "select" ? "select" : "button", + values, + messageId: consumed.messageId ?? params.interaction.message?.id, + }); + if (pluginDispatch === "handled") { + return; + } + } const eventText = formatDiscordComponentEventText({ kind: consumed.kind === "select" ? "select" : "button", label: consumed.label, @@ -1706,6 +1900,17 @@ class DiscordComponentModal extends Modal { guildEntries: this.ctx.guildEntries, }); const channelCtx = resolveDiscordChannelContext(interaction); + const allowNameMatching = isDangerousNameMatchingEnabled(this.ctx.discordConfig); + const channelConfig = resolveDiscordChannelConfigWithFallback({ + guildInfo, + channelId, + channelName: channelCtx.channelName, + channelSlug: channelCtx.channelSlug, + parentId: channelCtx.parentId, + parentName: channelCtx.parentName, + parentSlug: channelCtx.parentSlug, + scope: channelCtx.isThread ? "thread" : "channel", + }); const memberAllowed = await ensureGuildComponentMemberAllowed({ interaction, guildInfo, @@ -1717,12 +1922,37 @@ class DiscordComponentModal extends Modal { replyOpts, componentLabel: "form", unauthorizedReply: "You are not authorized to use this form.", - allowNameMatching: isDangerousNameMatchingEnabled(this.ctx.discordConfig), + allowNameMatching, }); if (!memberAllowed) { return; } + const modalAllowed = await ensureComponentUserAllowed({ + entry: { + id: modalEntry.id, + kind: "button", + label: modalEntry.title, + allowedUsers: modalEntry.allowedUsers, + }, + interaction, + user, + replyOpts, + componentLabel: "form", + unauthorizedReply: "You are not authorized to use this form.", + allowNameMatching, + }); + if (!modalAllowed) { + return; + } + const commandAuthorized = resolveComponentCommandAuthorized({ + ctx: this.ctx, + interactionCtx, + channelConfig, + guildInfo, + allowNameMatching, + }); + const consumed = resolveDiscordModalEntry({ id: modalId, consume: !modalEntry.reusable, @@ -1739,6 +1969,28 @@ class DiscordComponentModal extends Modal { return; } + if (consumed.callbackData) { + const fields = consumed.fields.map((field) => ({ + id: field.id, + name: field.name, + values: resolveModalFieldValues(field, interaction), + })); + const pluginDispatch = await dispatchPluginDiscordInteractiveEvent({ + ctx: this.ctx, + interaction, + interactionCtx, + channelCtx, + isAuthorizedSender: commandAuthorized, + data: consumed.callbackData, + kind: "modal", + fields, + messageId: consumed.messageId, + }); + if (pluginDispatch === "handled") { + return; + } + } + try { await interaction.acknowledge(); } catch (err) { diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index a7a5ff2f6ef..2fb14bafe8e 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -90,6 +90,20 @@ function createThreadClient(params: { threadId: string; parentId: string }): Dis } as unknown as DiscordClient; } +function createDmClient(channelId: string): DiscordClient { + return { + fetchChannel: async (id: string) => { + if (id === channelId) { + return { + id: channelId, + type: ChannelType.DM, + }; + } + return null; + }, + } as unknown as DiscordClient; +} + async function runThreadBoundPreflight(params: { threadId: string; parentId: string; @@ -157,6 +171,25 @@ async function runGuildPreflight(params: { }); } +async function runDmPreflight(params: { + channelId: string; + message: import("@buape/carbon").Message; + discordConfig: DiscordConfig; +}) { + return preflightDiscordMessage({ + ...createPreflightArgs({ + cfg: DEFAULT_PREFLIGHT_CFG, + discordConfig: params.discordConfig, + data: { + channel_id: params.channelId, + author: params.message.author, + message: params.message, + } as DiscordMessageEvent, + client: createDmClient(params.channelId), + }), + }); +} + async function runMentionOnlyBotPreflight(params: { channelId: string; guildId: string; @@ -258,6 +291,60 @@ describe("preflightDiscordMessage", () => { expect(result).toBeNull(); }); + it("restores direct-message bindings by user target instead of DM channel id", async () => { + registerSessionBindingAdapter({ + channel: "discord", + accountId: "default", + listBySession: () => [], + resolveByConversation: (ref) => + ref.conversationId === "user:user-1" + ? createThreadBinding({ + conversation: { + channel: "discord", + accountId: "default", + conversationId: "user:user-1", + }, + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + pluginRoot: "/Users/huntharo/github/openclaw-app-server", + }, + }) + : null, + }); + + const result = await runDmPreflight({ + channelId: "dm-channel-1", + message: createDiscordMessage({ + id: "m-dm-1", + channelId: "dm-channel-1", + content: "who are you", + author: { + id: "user-1", + bot: false, + username: "alice", + }, + }), + discordConfig: { + allowBots: true, + dmPolicy: "open", + } as DiscordConfig, + }); + + expect(result).not.toBeNull(); + expect(result?.threadBinding).toMatchObject({ + conversation: { + channel: "discord", + accountId: "default", + conversationId: "user:user-1", + }, + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + }, + }); + }); + it("keeps bound-thread regular bot messages flowing when allowBots=true", async () => { const threadBinding = createThreadBinding({ targetKind: "session", diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index d88b0cd03ec..77640784063 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -29,6 +29,7 @@ import { enqueueSystemEvent } from "../../../../src/infra/system-events.js"; import { logDebug } from "../../../../src/logger.js"; import { getChildLogger } from "../../../../src/logging.js"; import { buildPairingReply } from "../../../../src/pairing/pairing-messages.js"; +import { isPluginOwnedSessionBindingRecord } from "../../../../src/plugins/conversation-binding.js"; import { DEFAULT_ACCOUNT_ID } from "../../../../src/routing/session-key.js"; import { fetchPluralKitMessageInfo } from "../pluralkit.js"; import { sendMessageDiscord } from "../send.js"; @@ -350,12 +351,13 @@ export async function preflightDiscordMessage( }), parentConversationId: earlyThreadParentId, }); + const bindingConversationId = isDirectMessage ? `user:${author.id}` : messageChannelId; let threadBinding: SessionBindingRecord | undefined; threadBinding = getSessionBindingService().resolveByConversation({ channel: "discord", accountId: params.accountId, - conversationId: messageChannelId, + conversationId: bindingConversationId, parentConversationId: earlyThreadParentId, }) ?? undefined; const configuredRoute = @@ -384,7 +386,9 @@ export async function preflightDiscordMessage( logVerbose(`discord: drop bound-thread webhook echo message ${message.id}`); return null; } - const boundSessionKey = threadBinding?.targetSessionKey?.trim(); + const boundSessionKey = isPluginOwnedSessionBindingRecord(threadBinding) + ? "" + : threadBinding?.targetSessionKey?.trim(); const effectiveRoute = resolveDiscordEffectiveRoute({ route, boundSessionKey, @@ -392,7 +396,7 @@ export async function preflightDiscordMessage( matchedBy: "binding.channel", }); const boundAgentId = boundSessionKey ? effectiveRoute.agentId : undefined; - const isBoundThreadSession = Boolean(boundSessionKey && earlyThreadChannel); + const isBoundThreadSession = Boolean(threadBinding && earlyThreadChannel); if ( isBoundThreadBotSystemMessage({ isBoundThreadSession, diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts index b4d5478f921..da916c4bd2b 100644 --- a/extensions/discord/src/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -5,10 +5,12 @@ import type { StringSelectMenuInteraction, } from "@buape/carbon"; import type { Client } from "@buape/carbon"; +import { ChannelType } from "discord-api-types/v10"; import type { GatewayPresenceUpdate } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../../src/config/config.js"; import type { DiscordAccountConfig } from "../../../../src/config/types.discord.js"; +import { buildPluginBindingApprovalCustomId } from "../../../../src/plugins/conversation-binding.js"; import { buildAgentSessionKey } from "../../../../src/routing/resolve-route.js"; import { clearDiscordComponentEntries, @@ -52,6 +54,9 @@ const deliverDiscordReplyMock = vi.hoisted(() => vi.fn()); const recordInboundSessionMock = vi.hoisted(() => vi.fn()); const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn()); const resolveStorePathMock = vi.hoisted(() => vi.fn()); +const dispatchPluginInteractiveHandlerMock = vi.hoisted(() => vi.fn()); +const resolvePluginConversationBindingApprovalMock = vi.hoisted(() => vi.fn()); +const buildPluginBindingResolvedTextMock = vi.hoisted(() => vi.fn()); let lastDispatchCtx: Record | undefined; vi.mock("../../../../src/pairing/pairing-store.js", () => ({ @@ -88,6 +93,27 @@ vi.mock("../../../../src/config/sessions.js", async (importOriginal) => { }; }); +vi.mock("../../../../src/plugins/conversation-binding.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + resolvePluginConversationBindingApproval: (...args: unknown[]) => + resolvePluginConversationBindingApprovalMock(...args), + buildPluginBindingResolvedText: (...args: unknown[]) => + buildPluginBindingResolvedTextMock(...args), + }; +}); + +vi.mock("../../../../src/plugins/interactive.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchPluginInteractiveHandler: (...args: unknown[]) => + dispatchPluginInteractiveHandlerMock(...args), + }; +}); + describe("agent components", () => { const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig; @@ -341,6 +367,38 @@ describe("discord component interactions", () => { recordInboundSessionMock.mockClear().mockResolvedValue(undefined); readSessionUpdatedAtMock.mockClear().mockReturnValue(undefined); resolveStorePathMock.mockClear().mockReturnValue("/tmp/openclaw-sessions-test.json"); + dispatchPluginInteractiveHandlerMock.mockReset().mockResolvedValue({ + matched: false, + handled: false, + duplicate: false, + }); + resolvePluginConversationBindingApprovalMock.mockReset().mockResolvedValue({ + status: "approved", + binding: { + bindingId: "binding-1", + pluginId: "openclaw-codex-app-server", + pluginName: "OpenClaw App Server", + pluginRoot: "/plugins/codex", + channel: "discord", + accountId: "default", + conversationId: "user:123456789", + boundAt: Date.now(), + }, + request: { + id: "approval-1", + pluginId: "openclaw-codex-app-server", + pluginName: "OpenClaw App Server", + pluginRoot: "/plugins/codex", + requestedAt: Date.now(), + conversation: { + channel: "discord", + accountId: "default", + conversationId: "user:123456789", + }, + }, + decision: "allow-once", + }); + buildPluginBindingResolvedTextMock.mockReset().mockReturnValue("Binding approved."); }); it("routes button clicks with reply references", async () => { @@ -499,6 +557,200 @@ describe("discord component interactions", () => { expect(acknowledge).toHaveBeenCalledTimes(1); expect(resolveDiscordModalEntry({ id: "mdl_1", consume: false })).not.toBeNull(); }); + + it("passes false auth to plugin Discord interactions for non-allowlisted guild users", async () => { + registerDiscordComponentEntries({ + entries: [createButtonEntry({ callbackData: "codex:approve" })], + modals: [], + }); + dispatchPluginInteractiveHandlerMock.mockResolvedValue({ + matched: true, + handled: true, + duplicate: false, + }); + + const button = createDiscordComponentButton( + createComponentContext({ + cfg: { + commands: { useAccessGroups: true }, + channels: { discord: { replyToMode: "first" } }, + } as OpenClawConfig, + allowFrom: ["owner-1"], + }), + ); + const { interaction } = createComponentButtonInteraction({ + rawData: { + channel_id: "guild-channel", + guild_id: "guild-1", + id: "interaction-guild-plugin-1", + member: { roles: [] }, + } as unknown as ButtonInteraction["rawData"], + guild: { id: "guild-1", name: "Test Guild" } as unknown as ButtonInteraction["guild"], + }); + + await button.run(interaction, { cid: "btn_1" } as ComponentData); + + expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + auth: { isAuthorizedSender: false }, + }), + }), + ); + expect(dispatchReplyMock).not.toHaveBeenCalled(); + }); + + it("passes true auth to plugin Discord interactions for allowlisted guild users", async () => { + registerDiscordComponentEntries({ + entries: [createButtonEntry({ callbackData: "codex:approve" })], + modals: [], + }); + dispatchPluginInteractiveHandlerMock.mockResolvedValue({ + matched: true, + handled: true, + duplicate: false, + }); + + const button = createDiscordComponentButton( + createComponentContext({ + cfg: { + commands: { useAccessGroups: true }, + channels: { discord: { replyToMode: "first" } }, + } as OpenClawConfig, + allowFrom: ["123456789"], + }), + ); + const { interaction } = createComponentButtonInteraction({ + rawData: { + channel_id: "guild-channel", + guild_id: "guild-1", + id: "interaction-guild-plugin-2", + member: { roles: [] }, + } as unknown as ButtonInteraction["rawData"], + guild: { id: "guild-1", name: "Test Guild" } as unknown as ButtonInteraction["guild"], + }); + + await button.run(interaction, { cid: "btn_1" } as ComponentData); + + expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + auth: { isAuthorizedSender: true }, + }), + }), + ); + expect(dispatchReplyMock).not.toHaveBeenCalled(); + }); + + it("routes plugin Discord interactions in group DMs by channel id instead of sender id", async () => { + registerDiscordComponentEntries({ + entries: [createButtonEntry({ callbackData: "codex:approve" })], + modals: [], + }); + dispatchPluginInteractiveHandlerMock.mockResolvedValue({ + matched: true, + handled: true, + duplicate: false, + }); + + const button = createDiscordComponentButton(createComponentContext()); + const { interaction } = createComponentButtonInteraction({ + rawData: { + channel_id: "group-dm-1", + id: "interaction-group-dm-1", + } as unknown as ButtonInteraction["rawData"], + channel: { + id: "group-dm-1", + type: ChannelType.GroupDM, + } as unknown as ButtonInteraction["channel"], + }); + + await button.run(interaction, { cid: "btn_1" } as ComponentData); + + expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + conversationId: "channel:group-dm-1", + senderId: "123456789", + }), + }), + ); + expect(dispatchReplyMock).not.toHaveBeenCalled(); + }); + + it("does not fall through to Claw when a plugin Discord interaction already replied", async () => { + registerDiscordComponentEntries({ + entries: [createButtonEntry({ callbackData: "codex:approve" })], + modals: [], + }); + dispatchPluginInteractiveHandlerMock.mockImplementation(async (params: any) => { + await params.respond.reply({ text: "✓", ephemeral: true }); + return { + matched: true, + handled: true, + duplicate: false, + }; + }); + + const button = createDiscordComponentButton(createComponentContext()); + const { interaction, reply } = createComponentButtonInteraction(); + + await button.run(interaction, { cid: "btn_1" } as ComponentData); + + expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledTimes(1); + expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true }); + expect(dispatchReplyMock).not.toHaveBeenCalled(); + }); + + it("falls through to built-in Discord component routing when a plugin declines handling", async () => { + registerDiscordComponentEntries({ + entries: [createButtonEntry({ callbackData: "codex:approve" })], + modals: [], + }); + dispatchPluginInteractiveHandlerMock.mockResolvedValue({ + matched: true, + handled: false, + duplicate: false, + }); + + const button = createDiscordComponentButton(createComponentContext()); + const { interaction, reply } = createComponentButtonInteraction(); + + await button.run(interaction, { cid: "btn_1" } as ComponentData); + + expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledTimes(1); + expect(reply).toHaveBeenCalledWith({ content: "✓" }); + expect(dispatchReplyMock).toHaveBeenCalledTimes(1); + }); + + it("resolves plugin binding approvals without falling through to Claw", async () => { + registerDiscordComponentEntries({ + entries: [ + createButtonEntry({ + callbackData: buildPluginBindingApprovalCustomId("approval-1", "allow-once"), + }), + ], + modals: [], + }); + const button = createDiscordComponentButton(createComponentContext()); + const update = vi.fn().mockResolvedValue(undefined); + const followUp = vi.fn().mockResolvedValue(undefined); + const interaction = { + ...(createComponentButtonInteraction().interaction as any), + update, + followUp, + } as ButtonInteraction; + + await button.run(interaction, { cid: "btn_1" } as ComponentData); + + expect(resolvePluginConversationBindingApprovalMock).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith({ components: [] }); + expect(followUp).toHaveBeenCalledWith({ + content: "Binding approved.", + ephemeral: true, + }); + expect(dispatchReplyMock).not.toHaveBeenCalled(); + }); }); describe("resolveDiscordOwnerAllowFrom", () => { diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index bc038927d9c..49fe53843f3 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -6,6 +6,7 @@ import { Row, StringSelectMenu, TextDisplay, + type TopLevelComponents, type AutocompleteInteraction, type ButtonInteraction, type CommandInteraction, @@ -274,6 +275,12 @@ function hasRenderableReplyPayload(payload: ReplyPayload): boolean { if (payload.mediaUrls?.some((entry) => entry.trim())) { return true; } + const discordData = payload.channelData?.discord as + | { components?: TopLevelComponents[] } + | undefined; + if (Array.isArray(discordData?.components) && discordData.components.length > 0) { + return true; + } return false; } @@ -1772,13 +1779,25 @@ async function deliverDiscordInteractionReply(params: { const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params; const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const text = payload.text ?? ""; + const discordData = payload.channelData?.discord as + | { components?: TopLevelComponents[] } + | undefined; + let firstMessageComponents = + Array.isArray(discordData?.components) && discordData.components.length > 0 + ? discordData.components + : undefined; let hasReplied = false; - const sendMessage = async (content: string, files?: { name: string; data: Buffer }[]) => { + const sendMessage = async ( + content: string, + files?: { name: string; data: Buffer }[], + components?: TopLevelComponents[], + ) => { const payload = files && files.length > 0 ? { content, + ...(components ? { components } : {}), files: files.map((file) => { if (file.data instanceof Blob) { return { name: file.name, data: file.data }; @@ -1787,15 +1806,20 @@ async function deliverDiscordInteractionReply(params: { return { name: file.name, data: new Blob([arrayBuffer]) }; }), } - : { content }; + : { + content, + ...(components ? { components } : {}), + }; await safeDiscordInteractionCall("interaction send", async () => { if (!preferFollowUp && !hasReplied) { await interaction.reply(payload); hasReplied = true; + firstMessageComponents = undefined; return; } await interaction.followUp(payload); hasReplied = true; + firstMessageComponents = undefined; }); }; @@ -1820,7 +1844,7 @@ async function deliverDiscordInteractionReply(params: { chunks.push(text); } const caption = chunks[0] ?? ""; - await sendMessage(caption, media); + await sendMessage(caption, media, firstMessageComponents); for (const chunk of chunks.slice(1)) { if (!chunk.trim()) { continue; @@ -1830,7 +1854,7 @@ async function deliverDiscordInteractionReply(params: { return; } - if (!text.trim()) { + if (!text.trim() && !firstMessageComponents) { return; } const chunks = chunkDiscordTextWithMode(text, { @@ -1838,13 +1862,13 @@ async function deliverDiscordInteractionReply(params: { maxLines: maxLinesPerMessage, chunkMode, }); - if (!chunks.length && text) { + if (!chunks.length && (text || firstMessageComponents)) { chunks.push(text); } for (const chunk of chunks) { - if (!chunk.trim()) { + if (!chunk.trim() && !firstMessageComponents) { continue; } - await sendMessage(chunk); + await sendMessage(chunk, undefined, firstMessageComponents); } } diff --git a/extensions/discord/src/monitor/thread-bindings.discord-api.ts b/extensions/discord/src/monitor/thread-bindings.discord-api.ts index 38360b27728..134eda0f109 100644 --- a/extensions/discord/src/monitor/thread-bindings.discord-api.ts +++ b/extensions/discord/src/monitor/thread-bindings.discord-api.ts @@ -17,7 +17,7 @@ import { } from "./thread-bindings.types.js"; function buildThreadTarget(threadId: string): string { - return `channel:${threadId}`; + return /^(channel:|user:)/i.test(threadId) ? threadId : `channel:${threadId}`; } export function isThreadArchived(raw: unknown): boolean { diff --git a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts index 013952e7c71..ed221645fcf 100644 --- a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts +++ b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts @@ -7,6 +7,7 @@ import { setRuntimeConfigSnapshot, type OpenClawConfig, } from "../../../../src/config/config.js"; +import { getSessionBindingService } from "../../../../src/infra/outbound/session-binding-service.js"; const hoisted = vi.hoisted(() => { const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({})); @@ -788,6 +789,57 @@ describe("thread binding lifecycle", () => { expect(usedTokenNew).toBe(true); }); + it("binds current Discord DMs as direct conversation bindings", async () => { + createThreadBindingManager({ + accountId: "default", + persist: false, + enableSweeper: false, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + }); + + hoisted.restGet.mockClear(); + hoisted.restPost.mockClear(); + + const bound = await getSessionBindingService().bind({ + targetSessionKey: "plugin-binding:openclaw-codex-app-server:dm", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "user:1177378744822943744", + }, + placement: "current", + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + pluginRoot: "/Users/huntharo/github/openclaw-app-server", + }, + }); + + expect(bound).toMatchObject({ + conversation: { + channel: "discord", + accountId: "default", + conversationId: "user:1177378744822943744", + parentConversationId: "user:1177378744822943744", + }, + }); + expect( + getSessionBindingService().resolveByConversation({ + channel: "discord", + accountId: "default", + conversationId: "user:1177378744822943744", + }), + ).toMatchObject({ + conversation: { + conversationId: "user:1177378744822943744", + }, + }); + expect(hoisted.restGet).not.toHaveBeenCalled(); + expect(hoisted.restPost).not.toHaveBeenCalled(); + }); + it("keeps overlapping thread ids isolated per account", async () => { const a = createThreadBindingManager({ accountId: "a", @@ -948,6 +1000,47 @@ describe("thread binding lifecycle", () => { expect(manager.getByThreadId("thread-acp-uncertain")).toBeDefined(); }); + it("does not reconcile plugin-owned direct bindings as stale ACP sessions", async () => { + const manager = createThreadBindingManager({ + accountId: "default", + persist: false, + enableSweeper: false, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + }); + + await manager.bindTarget({ + threadId: "user:1177378744822943744", + channelId: "user:1177378744822943744", + targetKind: "acp", + targetSessionKey: "plugin-binding:openclaw-codex-app-server:dm", + agentId: "codex", + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + pluginRoot: "/Users/huntharo/github/openclaw-app-server", + }, + }); + + hoisted.readAcpSessionEntry.mockReturnValue(null); + + const result = await reconcileAcpThreadBindingsOnStartup({ + cfg: {} as OpenClawConfig, + accountId: "default", + }); + + expect(result.checked).toBe(0); + expect(result.removed).toBe(0); + expect(result.staleSessionKeys).toEqual([]); + expect(manager.getByThreadId("user:1177378744822943744")).toMatchObject({ + threadId: "user:1177378744822943744", + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + }, + }); + }); + it("removes ACP bindings when health probe marks running session as stale", async () => { const manager = createThreadBindingManager({ accountId: "default", diff --git a/extensions/discord/src/monitor/thread-bindings.lifecycle.ts b/extensions/discord/src/monitor/thread-bindings.lifecycle.ts index d7389d68439..d7d96857250 100644 --- a/extensions/discord/src/monitor/thread-bindings.lifecycle.ts +++ b/extensions/discord/src/monitor/thread-bindings.lifecycle.ts @@ -323,7 +323,12 @@ export async function reconcileAcpThreadBindingsOnStartup(params: { }; } - const acpBindings = manager.listBindings().filter((binding) => binding.targetKind === "acp"); + const acpBindings = manager + .listBindings() + .filter( + (binding) => + binding.targetKind === "acp" && binding.metadata?.pluginBindingOwner !== "plugin", + ); const staleBindings: ThreadBindingRecord[] = []; const probeTargets: Array<{ binding: ThreadBindingRecord; diff --git a/extensions/discord/src/monitor/thread-bindings.manager.ts b/extensions/discord/src/monitor/thread-bindings.manager.ts index 6595f053ea9..efa599cadc2 100644 --- a/extensions/discord/src/monitor/thread-bindings.manager.ts +++ b/extensions/discord/src/monitor/thread-bindings.manager.ts @@ -117,6 +117,11 @@ function toThreadBindingTargetKind(raw: BindingTargetKind): "subagent" | "acp" { return raw === "subagent" ? "subagent" : "acp"; } +function isDirectConversationBindingId(value?: string | null): boolean { + const trimmed = value?.trim(); + return Boolean(trimmed && /^(user:|channel:)/i.test(trimmed)); +} + function toSessionBindingRecord( record: ThreadBindingRecord, defaults: { idleTimeoutMs: number; maxAgeMs: number }, @@ -158,6 +163,7 @@ function toSessionBindingRecord( record, defaultMaxAgeMs: defaults.maxAgeMs, }), + ...record.metadata, }, }; } @@ -264,6 +270,8 @@ export function createThreadBindingManager( const cfg = resolveCurrentCfg(); let threadId = normalizeThreadId(bindParams.threadId); let channelId = bindParams.channelId?.trim() || ""; + const directConversationBinding = + isDirectConversationBindingId(threadId) || isDirectConversationBindingId(channelId); if (!threadId && bindParams.createThread) { if (!channelId) { @@ -287,6 +295,10 @@ export function createThreadBindingManager( return null; } + if (!channelId && directConversationBinding) { + channelId = threadId; + } + if (!channelId) { channelId = (await resolveChannelIdForBinding({ @@ -309,12 +321,12 @@ export function createThreadBindingManager( const targetKind = normalizeTargetKind(bindParams.targetKind, targetSessionKey); let webhookId = bindParams.webhookId?.trim() || ""; let webhookToken = bindParams.webhookToken?.trim() || ""; - if (!webhookId || !webhookToken) { + if (!directConversationBinding && (!webhookId || !webhookToken)) { const cachedWebhook = findReusableWebhook({ accountId, channelId }); webhookId = cachedWebhook.webhookId ?? ""; webhookToken = cachedWebhook.webhookToken ?? ""; } - if (!webhookId || !webhookToken) { + if (!directConversationBinding && (!webhookId || !webhookToken)) { const createdWebhook = await createWebhookForChannel({ cfg, accountId, @@ -341,6 +353,10 @@ export function createThreadBindingManager( lastActivityAt: now, idleTimeoutMs, maxAgeMs, + metadata: + bindParams.metadata && typeof bindParams.metadata === "object" + ? { ...bindParams.metadata } + : undefined, }; setBindingRecord(record); @@ -508,6 +524,9 @@ export function createThreadBindingManager( }); continue; } + if (isDirectConversationBindingId(binding.threadId)) { + continue; + } try { const channel = await rest.get(Routes.channel(binding.threadId)); if (!channel || typeof channel !== "object") { @@ -604,6 +623,7 @@ export function createThreadBindingManager( label, boundBy, introText, + metadata, }); return bound ? toSessionBindingRecord(bound, { diff --git a/extensions/discord/src/monitor/thread-bindings.state.ts b/extensions/discord/src/monitor/thread-bindings.state.ts index 892d7a46293..cfcbc65f3f5 100644 --- a/extensions/discord/src/monitor/thread-bindings.state.ts +++ b/extensions/discord/src/monitor/thread-bindings.state.ts @@ -183,6 +183,8 @@ function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBin typeof value.maxAgeMs === "number" && Number.isFinite(value.maxAgeMs) ? Math.max(0, Math.floor(value.maxAgeMs)) : undefined; + const metadata = + value.metadata && typeof value.metadata === "object" ? { ...value.metadata } : undefined; const legacyExpiresAt = typeof (value as { expiresAt?: unknown }).expiresAt === "number" && Number.isFinite((value as { expiresAt?: unknown }).expiresAt) @@ -222,6 +224,7 @@ function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBin lastActivityAt, idleTimeoutMs: migratedIdleTimeoutMs, maxAgeMs: migratedMaxAgeMs, + metadata, }; } diff --git a/extensions/discord/src/monitor/thread-bindings.types.ts b/extensions/discord/src/monitor/thread-bindings.types.ts index 228c81c58cc..2403958e385 100644 --- a/extensions/discord/src/monitor/thread-bindings.types.ts +++ b/extensions/discord/src/monitor/thread-bindings.types.ts @@ -17,6 +17,7 @@ export type ThreadBindingRecord = { idleTimeoutMs?: number; /** Hard max-age window in milliseconds from bind time (0 disables hard cap). */ maxAgeMs?: number; + metadata?: Record; }; export type PersistedThreadBindingRecord = ThreadBindingRecord & { @@ -56,6 +57,7 @@ export type ThreadBindingManager = { introText?: string; webhookId?: string; webhookToken?: string; + metadata?: Record; }) => Promise; unbindThread: (params: { threadId: string; diff --git a/extensions/discord/src/send.ts b/extensions/discord/src/send.ts index e0620977631..ec710d79b19 100644 --- a/extensions/discord/src/send.ts +++ b/extensions/discord/src/send.ts @@ -45,6 +45,7 @@ export { sendVoiceMessageDiscord, } from "./send.outbound.js"; export { sendDiscordComponentMessage } from "./send.components.js"; +export { sendTypingDiscord } from "./send.typing.js"; export { fetchChannelPermissionsDiscord, hasAllGuildPermissionsDiscord, diff --git a/extensions/discord/src/send.typing.ts b/extensions/discord/src/send.typing.ts new file mode 100644 index 00000000000..cf1db7fa484 --- /dev/null +++ b/extensions/discord/src/send.typing.ts @@ -0,0 +1,9 @@ +import { Routes } from "discord-api-types/v10"; +import { resolveDiscordRest } from "./client.js"; +import type { DiscordReactOpts } from "./send.types.js"; + +export async function sendTypingDiscord(channelId: string, opts: DiscordReactOpts = {}) { + const rest = resolveDiscordRest(opts); + await rest.post(Routes.channelTyping(channelId)); + return { ok: true, channelId }; +} diff --git a/extensions/discord/src/targets.test.ts b/extensions/discord/src/targets.test.ts index 527e0164ba8..fa8b739b3b5 100644 --- a/extensions/discord/src/targets.test.ts +++ b/extensions/discord/src/targets.test.ts @@ -1,13 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import { listDiscordDirectoryPeersLive } from "./directory-live.js"; +import * as directoryLive from "./directory-live.js"; import { normalizeDiscordMessagingTarget } from "./normalize.js"; import { parseDiscordTarget, resolveDiscordChannelId, resolveDiscordTarget } from "./targets.js"; -vi.mock("./directory-live.js", () => ({ - listDiscordDirectoryPeersLive: vi.fn(), -})); - describe("parseDiscordTarget", () => { it("parses user mention and prefixes", () => { const cases = [ @@ -73,14 +69,15 @@ describe("resolveDiscordChannelId", () => { describe("resolveDiscordTarget", () => { const cfg = { channels: { discord: {} } } as OpenClawConfig; - const listPeers = vi.mocked(listDiscordDirectoryPeersLive); beforeEach(() => { - listPeers.mockClear(); + vi.restoreAllMocks(); }); it("returns a resolved user for usernames", async () => { - listPeers.mockResolvedValueOnce([{ kind: "user", id: "user:999", name: "Jane" } as const]); + vi.spyOn(directoryLive, "listDiscordDirectoryPeersLive").mockResolvedValueOnce([ + { kind: "user", id: "user:999", name: "Jane" } as const, + ]); await expect( resolveDiscordTarget("jane", { cfg, accountId: "default" }), @@ -88,14 +85,14 @@ describe("resolveDiscordTarget", () => { }); it("falls back to parsing when lookup misses", async () => { - listPeers.mockResolvedValueOnce([]); + vi.spyOn(directoryLive, "listDiscordDirectoryPeersLive").mockResolvedValueOnce([]); await expect( resolveDiscordTarget("general", { cfg, accountId: "default" }), ).resolves.toMatchObject({ kind: "channel", id: "general" }); }); it("does not call directory lookup for explicit user ids", async () => { - listPeers.mockResolvedValueOnce([]); + const listPeers = vi.spyOn(directoryLive, "listDiscordDirectoryPeersLive"); await expect( resolveDiscordTarget("user:123", { cfg, accountId: "default" }), ).resolves.toMatchObject({ kind: "user", id: "123" }); diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 40e9a0b64e8..7c62501aa6f 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -43,6 +43,7 @@ function fakeApi(overrides: Partial = {}): OpenClawPluginApi registerCli() {}, registerService() {}, registerProvider() {}, + registerInteractiveHandler() {}, registerHook() {}, registerHttpRoute() {}, registerCommand() {}, diff --git a/extensions/phone-control/index.test.ts b/extensions/phone-control/index.test.ts index 2c3462c82a9..1eee0ff9d64 100644 --- a/extensions/phone-control/index.test.ts +++ b/extensions/phone-control/index.test.ts @@ -42,6 +42,12 @@ function createCommandContext(args: string): PluginCommandContext { commandBody: `/phone ${args}`, args, config: {}, + requestConversationBinding: async () => ({ + status: "error", + message: "unsupported", + }), + detachConversationBinding: async () => ({ removed: false }), + getCurrentConversationBinding: async () => null, }; } diff --git a/extensions/telegram/src/bot-handlers.ts b/extensions/telegram/src/bot-handlers.ts index 295c4092ec6..88e61e1c567 100644 --- a/extensions/telegram/src/bot-handlers.ts +++ b/extensions/telegram/src/bot-handlers.ts @@ -33,6 +33,12 @@ import { danger, logVerbose, warn } from "../../../src/globals.js"; import { enqueueSystemEvent } from "../../../src/infra/system-events.js"; import { MediaFetchError } from "../../../src/media/fetch.js"; import { readChannelAllowFromStore } from "../../../src/pairing/pairing-store.js"; +import { + buildPluginBindingResolvedText, + parsePluginBindingApprovalCustomId, + resolvePluginConversationBindingApproval, +} from "../../../src/plugins/conversation-binding.js"; +import { dispatchPluginInteractiveHandler } from "../../../src/plugins/interactive.js"; import { resolveAgentRoute } from "../../../src/routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../../src/routing/session-key.js"; import { applyModelOverrideToSessionEntry } from "../../../src/sessions/model-overrides.js"; @@ -1121,6 +1127,24 @@ export const registerTelegramHandlers = ({ } return await editCallbackMessage(messageText, replyMarkup); }; + const editCallbackButtons = async ( + buttons: Array< + Array<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }> + >, + ) => { + const keyboard = buildInlineKeyboard(buttons) ?? { inline_keyboard: [] }; + const replyMarkup = { reply_markup: keyboard }; + const editReplyMarkupFn = (ctx as { editMessageReplyMarkup?: unknown }) + .editMessageReplyMarkup; + if (typeof editReplyMarkupFn === "function") { + return await ctx.editMessageReplyMarkup(replyMarkup); + } + return await bot.api.editMessageReplyMarkup( + callbackMessage.chat.id, + callbackMessage.message_id, + replyMarkup, + ); + }; const deleteCallbackMessage = async () => { const deleteFn = (ctx as { deleteMessage?: unknown }).deleteMessage; if (typeof deleteFn === "function") { @@ -1201,6 +1225,70 @@ export const registerTelegramHandlers = ({ return; } + const callbackConversationId = + messageThreadId != null ? `${chatId}:topic:${messageThreadId}` : String(chatId); + const pluginBindingApproval = parsePluginBindingApprovalCustomId(data); + if (pluginBindingApproval) { + const resolved = await resolvePluginConversationBindingApproval({ + approvalId: pluginBindingApproval.approvalId, + decision: pluginBindingApproval.decision, + senderId: senderId || undefined, + }); + await clearCallbackButtons(); + await replyToCallbackChat(buildPluginBindingResolvedText(resolved)); + return; + } + const pluginCallback = await dispatchPluginInteractiveHandler({ + channel: "telegram", + data, + callbackId: callback.id, + ctx: { + accountId, + callbackId: callback.id, + conversationId: callbackConversationId, + parentConversationId: messageThreadId != null ? String(chatId) : undefined, + senderId: senderId || undefined, + senderUsername: senderUsername || undefined, + threadId: messageThreadId, + isGroup, + isForum, + auth: { + isAuthorizedSender: true, + }, + callbackMessage: { + messageId: callbackMessage.message_id, + chatId: String(chatId), + messageText: callbackMessage.text ?? callbackMessage.caption, + }, + }, + respond: { + reply: async ({ text, buttons }) => { + await replyToCallbackChat( + text, + buttons ? { reply_markup: buildInlineKeyboard(buttons) } : undefined, + ); + }, + editMessage: async ({ text, buttons }) => { + await editCallbackMessage( + text, + buttons ? { reply_markup: buildInlineKeyboard(buttons) } : undefined, + ); + }, + editButtons: async ({ buttons }) => { + await editCallbackButtons(buttons); + }, + clearButtons: async () => { + await clearCallbackButtons(); + }, + deleteMessage: async () => { + await deleteCallbackMessage(); + }, + }, + }); + if (pluginCallback.handled) { + return; + } + if (isApprovalCallback) { if ( !isTelegramExecApprovalClientEnabled({ cfg, accountId }) || diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index db19faa8fe3..9468f64c789 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -1,5 +1,10 @@ import { rm } from "node:fs/promises"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearPluginInteractiveHandlers, + registerPluginInteractiveHandler, +} from "../../../src/plugins/interactive.js"; +import type { PluginInteractiveTelegramHandlerContext } from "../../../src/plugins/types.js"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; import { @@ -49,6 +54,7 @@ describe("createTelegramBot", () => { beforeEach(() => { setMyCommandsSpy.mockClear(); + clearPluginInteractiveHandlers(); loadConfig.mockReturnValue({ agents: { defaults: { @@ -201,7 +207,7 @@ describe("createTelegramBot", () => { }, }, }); - const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( + const callbackHandler = getOnHandler("callback_query") as ( ctx: Record, ) => Promise; expect(callbackHandler).toBeDefined(); @@ -244,7 +250,7 @@ describe("createTelegramBot", () => { }, }, }); - const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( + const callbackHandler = getOnHandler("callback_query") as ( ctx: Record, ) => Promise; expect(callbackHandler).toBeDefined(); @@ -288,7 +294,7 @@ describe("createTelegramBot", () => { }, }); createTelegramBot({ token: "tok" }); - const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( + const callbackHandler = getOnHandler("callback_query") as ( ctx: Record, ) => Promise; expect(callbackHandler).toBeDefined(); @@ -1359,6 +1365,57 @@ describe("createTelegramBot", () => { expect(replySpy).not.toHaveBeenCalled(); }); + + it.skip("routes plugin-owned callback namespaces before synthetic command fallback", async () => { + onSpy.mockClear(); + replySpy.mockClear(); + editMessageTextSpy.mockClear(); + sendMessageSpy.mockClear(); + registerPluginInteractiveHandler("codex-plugin", { + channel: "telegram", + namespace: "codex", + handler: async ({ respond, callback }: PluginInteractiveTelegramHandlerContext) => { + await respond.editMessage({ + text: `Handled ${callback.payload}`, + }); + return { handled: true }; + }, + }); + + createTelegramBot({ + token: "tok", + config: { + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + }, + }); + const callbackHandler = getOnHandler("callback_query") as ( + ctx: Record, + ) => Promise; + + await callbackHandler({ + callbackQuery: { + id: "cbq-codex-1", + data: "codex:resume:thread-1", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 11, + text: "Select a thread", + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(editMessageTextSpy).toHaveBeenCalledWith(1234, 11, "Handled resume:thread-1", undefined); + expect(replySpy).not.toHaveBeenCalled(); + }); it("sets command target session key for dm topic commands", async () => { onSpy.mockClear(); sendMessageSpy.mockClear(); diff --git a/extensions/telegram/src/conversation-route.ts b/extensions/telegram/src/conversation-route.ts index ea48592eadb..f12c896d0ca 100644 --- a/extensions/telegram/src/conversation-route.ts +++ b/extensions/telegram/src/conversation-route.ts @@ -2,6 +2,7 @@ import { resolveConfiguredAcpRoute } from "../../../src/acp/persistent-bindings. import type { OpenClawConfig } from "../../../src/config/config.js"; import { logVerbose } from "../../../src/globals.js"; import { getSessionBindingService } from "../../../src/infra/outbound/session-binding-service.js"; +import { isPluginOwnedSessionBindingRecord } from "../../../src/plugins/conversation-binding.js"; import { buildAgentSessionKey, deriveLastRoutePolicy, @@ -118,21 +119,25 @@ export function resolveTelegramConversationRoute(params: { }); const boundSessionKey = threadBinding?.targetSessionKey?.trim(); if (threadBinding && boundSessionKey) { - route = { - ...route, - sessionKey: boundSessionKey, - agentId: resolveAgentIdFromSessionKey(boundSessionKey), - lastRoutePolicy: deriveLastRoutePolicy({ + if (!isPluginOwnedSessionBindingRecord(threadBinding)) { + route = { + ...route, sessionKey: boundSessionKey, - mainSessionKey: route.mainSessionKey, - }), - matchedBy: "binding.channel", - }; + agentId: resolveAgentIdFromSessionKey(boundSessionKey), + lastRoutePolicy: deriveLastRoutePolicy({ + sessionKey: boundSessionKey, + mainSessionKey: route.mainSessionKey, + }), + matchedBy: "binding.channel", + }; + } configuredBinding = null; configuredBindingSessionKey = ""; getSessionBindingService().touch(threadBinding.bindingId); logVerbose( - `telegram: routed via bound conversation ${threadBindingConversationId} -> ${boundSessionKey}`, + isPluginOwnedSessionBindingRecord(threadBinding) + ? `telegram: plugin-bound conversation ${threadBindingConversationId}` + : `telegram: routed via bound conversation ${threadBindingConversationId} -> ${boundSessionKey}`, ); } } diff --git a/extensions/telegram/src/send.test-harness.ts b/extensions/telegram/src/send.test-harness.ts index 6d53a3d20e7..604a7d27dd1 100644 --- a/extensions/telegram/src/send.test-harness.ts +++ b/extensions/telegram/src/send.test-harness.ts @@ -4,7 +4,10 @@ import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js"; const { botApi, botCtorSpy } = vi.hoisted(() => ({ botApi: { deleteMessage: vi.fn(), + editForumTopic: vi.fn(), editMessageText: vi.fn(), + editMessageReplyMarkup: vi.fn(), + pinChatMessage: vi.fn(), sendChatAction: vi.fn(), sendMessage: vi.fn(), sendPoll: vi.fn(), @@ -16,6 +19,7 @@ const { botApi, botCtorSpy } = vi.hoisted(() => ({ sendAnimation: vi.fn(), setMessageReaction: vi.fn(), sendSticker: vi.fn(), + unpinChatMessage: vi.fn(), }, botCtorSpy: vi.fn(), })); diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index 7a29ecf07de..8a234ce92cb 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -16,11 +16,14 @@ const { buildInlineKeyboard, createForumTopicTelegram, editMessageTelegram, + pinMessageTelegram, reactMessageTelegram, + renameForumTopicTelegram, sendMessageTelegram, sendTypingTelegram, sendPollTelegram, sendStickerTelegram, + unpinMessageTelegram, } = await importTelegramSendModule(); async function expectChatNotFoundWithChatId( @@ -215,6 +218,45 @@ describe("sendMessageTelegram", () => { }); }); + it("pins and unpins Telegram messages", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + botToken: "tok", + }, + }, + }); + botApi.pinChatMessage.mockResolvedValue(true); + botApi.unpinChatMessage.mockResolvedValue(true); + + await pinMessageTelegram("-1001234567890", 101, { accountId: "default" }); + await unpinMessageTelegram("-1001234567890", 101, { accountId: "default" }); + + expect(botApi.pinChatMessage).toHaveBeenCalledWith("-1001234567890", 101, { + disable_notification: true, + }); + expect(botApi.unpinChatMessage).toHaveBeenCalledWith("-1001234567890", 101); + }); + + it("renames a Telegram forum topic", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + botToken: "tok", + }, + }, + }); + botApi.editForumTopic.mockResolvedValue(true); + + await renameForumTopicTelegram("-1001234567890", 271, "Codex Thread", { + accountId: "default", + }); + + expect(botApi.editForumTopic).toHaveBeenCalledWith("-1001234567890", 271, { + name: "Codex Thread", + }); + }); + it("applies timeoutSeconds config precedence", async () => { const cases = [ { diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index e7d2c48e9fc..89d6f7d337d 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -1067,6 +1067,109 @@ export async function deleteMessageTelegram( return { ok: true }; } +export async function pinMessageTelegram( + chatIdInput: string | number, + messageIdInput: string | number, + opts: TelegramDeleteOpts = {}, +): Promise<{ ok: true; messageId: string; chatId: string }> { + const { cfg, account, api } = resolveTelegramApiContext(opts); + const rawTarget = String(chatIdInput); + const chatId = await resolveAndPersistChatId({ + cfg, + api, + lookupTarget: rawTarget, + persistTarget: rawTarget, + verbose: opts.verbose, + }); + const messageId = normalizeMessageId(messageIdInput); + const requestWithDiag = createTelegramRequestWithDiag({ + cfg, + account, + retry: opts.retry, + verbose: opts.verbose, + }); + await requestWithDiag( + () => api.pinChatMessage(chatId, messageId, { disable_notification: true }), + "pinChatMessage", + ); + logVerbose(`[telegram] Pinned message ${messageId} in chat ${chatId}`); + return { ok: true, messageId: String(messageId), chatId }; +} + +export async function unpinMessageTelegram( + chatIdInput: string | number, + messageIdInput?: string | number, + opts: TelegramDeleteOpts = {}, +): Promise<{ ok: true; chatId: string; messageId?: string }> { + const { cfg, account, api } = resolveTelegramApiContext(opts); + const rawTarget = String(chatIdInput); + const chatId = await resolveAndPersistChatId({ + cfg, + api, + lookupTarget: rawTarget, + persistTarget: rawTarget, + verbose: opts.verbose, + }); + const messageId = messageIdInput === undefined ? undefined : normalizeMessageId(messageIdInput); + const requestWithDiag = createTelegramRequestWithDiag({ + cfg, + account, + retry: opts.retry, + verbose: opts.verbose, + }); + await requestWithDiag(() => api.unpinChatMessage(chatId, messageId), "unpinChatMessage"); + logVerbose( + `[telegram] Unpinned ${messageId != null ? `message ${messageId}` : "active message"} in chat ${chatId}`, + ); + return { + ok: true, + chatId, + ...(messageId != null ? { messageId: String(messageId) } : {}), + }; +} + +export async function renameForumTopicTelegram( + chatIdInput: string | number, + messageThreadIdInput: string | number, + name: string, + opts: TelegramDeleteOpts = {}, +): Promise<{ ok: true; chatId: string; messageThreadId: number; name: string }> { + const trimmedName = name.trim(); + if (!trimmedName) { + throw new Error("Telegram forum topic name is required"); + } + if (trimmedName.length > 128) { + throw new Error("Telegram forum topic name must be 128 characters or fewer"); + } + const { cfg, account, api } = resolveTelegramApiContext(opts); + const rawTarget = String(chatIdInput); + const chatId = await resolveAndPersistChatId({ + cfg, + api, + lookupTarget: rawTarget, + persistTarget: rawTarget, + verbose: opts.verbose, + }); + const messageThreadId = normalizeMessageId(messageThreadIdInput); + const requestWithDiag = createTelegramRequestWithDiag({ + cfg, + account, + retry: opts.retry, + verbose: opts.verbose, + }); + await requestWithDiag( + () => api.editForumTopic(chatId, messageThreadId, { name: trimmedName }), + "editForumTopic", + ); + logVerbose(`[telegram] Renamed forum topic ${messageThreadId} in chat ${chatId}`); + return { + ok: true, + chatId, + messageThreadId, + name: trimmedName, + }; +} + type TelegramEditOpts = { token?: string; accountId?: string; diff --git a/extensions/telegram/src/thread-bindings.test.ts b/extensions/telegram/src/thread-bindings.test.ts index 3b05f50ac9b..39b9c63338b 100644 --- a/extensions/telegram/src/thread-bindings.test.ts +++ b/extensions/telegram/src/thread-bindings.test.ts @@ -211,4 +211,40 @@ describe("telegram thread bindings", () => { ); expect(fs.existsSync(statePath)).toBe(false); }); + + it("persists unbinds before restart so removed bindings do not come back", async () => { + stateDirOverride = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-bindings-")); + process.env.OPENCLAW_STATE_DIR = stateDirOverride; + + createTelegramThreadBindingManager({ + accountId: "default", + persist: true, + enableSweeper: false, + }); + + const bound = await getSessionBindingService().bind({ + targetSessionKey: "plugin-binding:openclaw-codex-app-server:abc123", + targetKind: "session", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "8460800771", + }, + }); + + await getSessionBindingService().unbind({ + bindingId: bound.bindingId, + reason: "test-detach", + }); + + __testing.resetTelegramThreadBindingsForTests(); + + const reloaded = createTelegramThreadBindingManager({ + accountId: "default", + persist: true, + enableSweeper: false, + }); + + expect(reloaded.getByConversationId("8460800771")).toBeUndefined(); + }); }); diff --git a/extensions/telegram/src/thread-bindings.ts b/extensions/telegram/src/thread-bindings.ts index 831e46d952f..d10fef7f72c 100644 --- a/extensions/telegram/src/thread-bindings.ts +++ b/extensions/telegram/src/thread-bindings.ts @@ -34,6 +34,7 @@ export type TelegramThreadBindingRecord = { lastActivityAt: number; idleTimeoutMs?: number; maxAgeMs?: number; + metadata?: Record; }; type StoredTelegramBindingState = { @@ -173,6 +174,7 @@ function toSessionBindingRecord( typeof record.maxAgeMs === "number" ? Math.max(0, Math.floor(record.maxAgeMs)) : defaults.maxAgeMs, + ...record.metadata, }, }; } @@ -214,6 +216,10 @@ function fromSessionBindingInput(params: { : existing?.boundBy, boundAt: now, lastActivityAt: now, + metadata: { + ...existing?.metadata, + ...metadata, + }, }; if (typeof metadata.idleTimeoutMs === "number" && Number.isFinite(metadata.idleTimeoutMs)) { @@ -299,6 +305,9 @@ function loadBindingsFromDisk(accountId: string): TelegramThreadBindingRecord[] if (typeof entry?.boundBy === "string" && entry.boundBy.trim()) { record.boundBy = entry.boundBy.trim(); } + if (entry?.metadata && typeof entry.metadata === "object") { + record.metadata = { ...entry.metadata }; + } bindings.push(record); } return bindings; @@ -535,7 +544,7 @@ export function createTelegramThreadBindingManager( resolveBindingKey({ accountId, conversationId }), record, ); - void persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() }); + await persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() }); logVerbose( `telegram: bound conversation ${conversationId} -> ${targetSessionKey} (${summarizeLifecycleForLog( record, @@ -595,6 +604,9 @@ export function createTelegramThreadBindingManager( reason: input.reason, sendFarewell: false, }); + if (removed.length > 0) { + await persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() }); + } return removed.map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, @@ -614,6 +626,9 @@ export function createTelegramThreadBindingManager( reason: input.reason, sendFarewell: false, }); + if (removed) { + await persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() }); + } return removed ? [ toSessionBindingRecord(removed, { diff --git a/extensions/test-utils/plugin-api.ts b/extensions/test-utils/plugin-api.ts index 5c9693c1a80..a757344bd31 100644 --- a/extensions/test-utils/plugin-api.ts +++ b/extensions/test-utils/plugin-api.ts @@ -14,6 +14,7 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi registerCli() {}, registerService() {}, registerProvider() {}, + registerInteractiveHandler() {}, registerCommand() {}, registerContextEngine() {}, resolvePath(input: string) { diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 666964eb865..ed41db9664e 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -23,8 +23,17 @@ const diagnosticMocks = vi.hoisted(() => ({ logSessionStateChange: vi.fn(), })); const hookMocks = vi.hoisted(() => ({ + registry: { + plugins: [] as Array<{ + id: string; + status: "loaded" | "disabled" | "error"; + }>, + }, runner: { hasHooks: vi.fn(() => false), + runInboundClaim: vi.fn(async () => undefined), + runInboundClaimForPlugin: vi.fn(async () => undefined), + runInboundClaimForPluginOutcome: vi.fn(async () => ({ status: "no_handler" as const })), runMessageReceived: vi.fn(async () => {}), }, })); @@ -40,6 +49,15 @@ const acpMocks = vi.hoisted(() => ({ })); const sessionBindingMocks = vi.hoisted(() => ({ listBySession: vi.fn<(targetSessionKey: string) => SessionBindingRecord[]>(() => []), + resolveByConversation: vi.fn< + (ref: { + channel: string; + accountId: string; + conversationId: string; + parentConversationId?: string; + }) => SessionBindingRecord | null + >(() => null), + touch: vi.fn(), })); const sessionStoreMocks = vi.hoisted(() => ({ currentEntry: undefined as Record | undefined, @@ -125,6 +143,7 @@ vi.mock("../../config/sessions.js", async (importOriginal) => { vi.mock("../../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => hookMocks.runner, + getGlobalPluginRegistry: () => hookMocks.registry, })); vi.mock("../../hooks/internal-hooks.js", () => ({ createInternalHookEvent: internalHookMocks.createInternalHookEvent, @@ -155,8 +174,8 @@ vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal })), listBySession: (targetSessionKey: string) => sessionBindingMocks.listBySession(targetSessionKey), - resolveByConversation: vi.fn(() => null), - touch: vi.fn(), + resolveByConversation: sessionBindingMocks.resolveByConversation, + touch: sessionBindingMocks.touch, unbind: vi.fn(async () => []), }), }; @@ -170,6 +189,7 @@ vi.mock("../../tts/tts.js", () => ({ const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js"); const { resetInboundDedupe } = await import("./inbound-dedupe.js"); const { __testing: acpManagerTesting } = await import("../../acp/control-plane/manager.js"); +const { __testing: pluginBindingTesting } = await import("../../plugins/conversation-binding.js"); const noAbortResult = { handled: false, aborted: false } as const; const emptyConfig = {} as OpenClawConfig; @@ -239,7 +259,16 @@ describe("dispatchReplyFromConfig", () => { diagnosticMocks.logSessionStateChange.mockClear(); hookMocks.runner.hasHooks.mockClear(); hookMocks.runner.hasHooks.mockReturnValue(false); + hookMocks.runner.runInboundClaim.mockClear(); + hookMocks.runner.runInboundClaim.mockResolvedValue(undefined); + hookMocks.runner.runInboundClaimForPlugin.mockClear(); + hookMocks.runner.runInboundClaimForPlugin.mockResolvedValue(undefined); + hookMocks.runner.runInboundClaimForPluginOutcome.mockClear(); + hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({ + status: "no_handler", + }); hookMocks.runner.runMessageReceived.mockClear(); + hookMocks.registry.plugins = []; internalHookMocks.createInternalHookEvent.mockClear(); internalHookMocks.createInternalHookEvent.mockImplementation(createInternalHookEventPayload); internalHookMocks.triggerInternalHook.mockClear(); @@ -250,6 +279,10 @@ describe("dispatchReplyFromConfig", () => { acpMocks.requireAcpRuntimeBackend.mockReset(); sessionBindingMocks.listBySession.mockReset(); sessionBindingMocks.listBySession.mockReturnValue([]); + pluginBindingTesting.reset(); + sessionBindingMocks.resolveByConversation.mockReset(); + sessionBindingMocks.resolveByConversation.mockReturnValue(null); + sessionBindingMocks.touch.mockReset(); sessionStoreMocks.currentEntry = undefined; sessionStoreMocks.loadSessionStore.mockClear(); sessionStoreMocks.resolveStorePath.mockClear(); @@ -1861,6 +1894,71 @@ describe("dispatchReplyFromConfig", () => { ); }); + it("does not broadcast inbound claims without a core-owned plugin binding", async () => { + setNoAbort(); + hookMocks.runner.hasHooks.mockImplementation( + ((hookName?: string) => + hookName === "inbound_claim" || hookName === "message_received") as () => boolean, + ); + hookMocks.runner.runInboundClaim.mockResolvedValue({ handled: true } as never); + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "telegram", + Surface: "telegram", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:-10099", + To: "telegram:-10099", + AccountId: "default", + SenderId: "user-9", + SenderUsername: "ada", + MessageThreadId: 77, + CommandAuthorized: true, + WasMentioned: true, + CommandBody: "who are you", + RawBody: "who are you", + Body: "who are you", + MessageSid: "msg-claim-1", + SessionKey: "agent:main:telegram:group:-10099:77", + }); + const replyResolver = vi.fn(async () => ({ text: "core reply" }) satisfies ReplyPayload); + + const result = await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(result).toEqual({ queuedFinal: true, counts: { tool: 0, block: 0, final: 0 } }); + expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled(); + expect(hookMocks.runner.runMessageReceived).toHaveBeenCalledWith( + expect.objectContaining({ + from: ctx.From, + content: "who are you", + metadata: expect.objectContaining({ + messageId: "msg-claim-1", + originatingChannel: "telegram", + originatingTo: "telegram:-10099", + senderId: "user-9", + senderUsername: "ada", + threadId: 77, + }), + }), + expect.objectContaining({ + channelId: "telegram", + accountId: "default", + conversationId: "telegram:-10099", + }), + ); + expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledWith( + expect.objectContaining({ + type: "message", + action: "received", + sessionKey: "agent:main:telegram:group:-10099:77", + }), + ); + expect(replyResolver).toHaveBeenCalledTimes(1); + expect(dispatcher.sendFinalReply).toHaveBeenCalledWith( + expect.objectContaining({ text: "core reply" }), + ); + }); + it("emits internal message:received hook when a session key is available", async () => { setNoAbort(); const cfg = emptyConfig; @@ -1944,6 +2042,411 @@ describe("dispatchReplyFromConfig", () => { ); }); + it("routes plugin-owned bindings to the owning plugin before generic inbound claim broadcast", async () => { + setNoAbort(); + hookMocks.runner.hasHooks.mockImplementation( + ((hookName?: string) => + hookName === "inbound_claim" || hookName === "message_received") as () => boolean, + ); + hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }]; + hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({ + status: "handled", + result: { handled: true }, + }); + sessionBindingMocks.resolveByConversation.mockReturnValue({ + bindingId: "binding-1", + targetSessionKey: "plugin-binding:codex:abc123", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:1481858418548412579", + }, + status: "active", + boundAt: 1710000000000, + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + pluginRoot: "/Users/huntharo/github/openclaw-app-server", + }, + } satisfies SessionBindingRecord); + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + OriginatingTo: "discord:channel:1481858418548412579", + To: "discord:channel:1481858418548412579", + AccountId: "default", + SenderId: "user-9", + SenderUsername: "ada", + CommandAuthorized: true, + WasMentioned: false, + CommandBody: "who are you", + RawBody: "who are you", + Body: "who are you", + MessageSid: "msg-claim-plugin-1", + SessionKey: "agent:main:discord:channel:1481858418548412579", + }); + const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload); + + const result = await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(result).toEqual({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }); + expect(sessionBindingMocks.touch).toHaveBeenCalledWith("binding-1"); + expect(hookMocks.runner.runInboundClaimForPluginOutcome).toHaveBeenCalledWith( + "openclaw-codex-app-server", + expect.objectContaining({ + channel: "discord", + accountId: "default", + conversationId: "channel:1481858418548412579", + content: "who are you", + }), + expect.objectContaining({ + channelId: "discord", + accountId: "default", + conversationId: "channel:1481858418548412579", + }), + ); + expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled(); + expect(replyResolver).not.toHaveBeenCalled(); + }); + + it("routes plugin-owned Discord DM bindings to the owning plugin before generic inbound claim broadcast", async () => { + setNoAbort(); + hookMocks.runner.hasHooks.mockImplementation( + ((hookName?: string) => + hookName === "inbound_claim" || hookName === "message_received") as () => boolean, + ); + hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }]; + hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({ + status: "handled", + result: { handled: true }, + }); + sessionBindingMocks.resolveByConversation.mockReturnValue({ + bindingId: "binding-dm-1", + targetSessionKey: "plugin-binding:codex:dm123", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "user:1177378744822943744", + }, + status: "active", + boundAt: 1710000000000, + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + pluginRoot: "/Users/huntharo/github/openclaw-app-server", + }, + } satisfies SessionBindingRecord); + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + From: "discord:1177378744822943744", + OriginatingTo: "channel:1480574946919846079", + To: "channel:1480574946919846079", + AccountId: "default", + SenderId: "user-9", + SenderUsername: "ada", + CommandAuthorized: true, + WasMentioned: false, + CommandBody: "who are you", + RawBody: "who are you", + Body: "who are you", + MessageSid: "msg-claim-plugin-dm-1", + SessionKey: "agent:main:discord:user:1177378744822943744", + }); + const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload); + + const result = await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(result).toEqual({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }); + expect(sessionBindingMocks.touch).toHaveBeenCalledWith("binding-dm-1"); + expect(hookMocks.runner.runInboundClaimForPluginOutcome).toHaveBeenCalledWith( + "openclaw-codex-app-server", + expect.objectContaining({ + channel: "discord", + accountId: "default", + conversationId: "user:1177378744822943744", + content: "who are you", + }), + expect.objectContaining({ + channelId: "discord", + accountId: "default", + conversationId: "user:1177378744822943744", + }), + ); + expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled(); + expect(replyResolver).not.toHaveBeenCalled(); + }); + + it("falls back to OpenClaw once per startup when a bound plugin is missing", async () => { + setNoAbort(); + hookMocks.runner.hasHooks.mockImplementation( + ((hookName?: string) => + hookName === "inbound_claim" || hookName === "message_received") as () => boolean, + ); + hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({ + status: "missing_plugin", + }); + sessionBindingMocks.resolveByConversation.mockReturnValue({ + bindingId: "binding-missing-1", + targetSessionKey: "plugin-binding:codex:missing123", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:missing-plugin", + }, + status: "active", + boundAt: 1710000000000, + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + pluginName: "Codex App Server", + pluginRoot: "/Users/huntharo/github/openclaw-app-server", + detachHint: "/codex_detach", + }, + } satisfies SessionBindingRecord); + + const replyResolver = vi.fn(async () => ({ text: "openclaw fallback" }) satisfies ReplyPayload); + + const firstDispatcher = createDispatcher(); + await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + OriginatingTo: "discord:channel:missing-plugin", + To: "discord:channel:missing-plugin", + AccountId: "default", + MessageSid: "msg-missing-plugin-1", + SessionKey: "agent:main:discord:channel:missing-plugin", + CommandBody: "hello", + RawBody: "hello", + Body: "hello", + }), + cfg: emptyConfig, + dispatcher: firstDispatcher, + replyResolver, + }); + + const firstNotice = (firstDispatcher.sendToolResult as ReturnType).mock + .calls[0]?.[0] as ReplyPayload | undefined; + expect(firstNotice?.text).toContain("Routing this message to OpenClaw instead."); + expect(firstNotice?.text).toContain("/codex_detach"); + expect(replyResolver).toHaveBeenCalledTimes(1); + expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled(); + + replyResolver.mockClear(); + hookMocks.runner.runInboundClaim.mockClear(); + + const secondDispatcher = createDispatcher(); + await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + OriginatingTo: "discord:channel:missing-plugin", + To: "discord:channel:missing-plugin", + AccountId: "default", + MessageSid: "msg-missing-plugin-2", + SessionKey: "agent:main:discord:channel:missing-plugin", + CommandBody: "still there?", + RawBody: "still there?", + Body: "still there?", + }), + cfg: emptyConfig, + dispatcher: secondDispatcher, + replyResolver, + }); + + expect(secondDispatcher.sendToolResult).not.toHaveBeenCalled(); + expect(replyResolver).toHaveBeenCalledTimes(1); + expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled(); + }); + + it("falls back to OpenClaw when the bound plugin is loaded but has no inbound_claim handler", async () => { + setNoAbort(); + hookMocks.runner.hasHooks.mockImplementation( + ((hookName?: string) => + hookName === "inbound_claim" || hookName === "message_received") as () => boolean, + ); + hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }]; + hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({ + status: "no_handler", + }); + sessionBindingMocks.resolveByConversation.mockReturnValue({ + bindingId: "binding-no-handler-1", + targetSessionKey: "plugin-binding:codex:nohandler123", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:no-handler", + }, + status: "active", + boundAt: 1710000000000, + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + pluginName: "Codex App Server", + pluginRoot: "/Users/huntharo/github/openclaw-app-server", + }, + } satisfies SessionBindingRecord); + const dispatcher = createDispatcher(); + const replyResolver = vi.fn(async () => ({ text: "openclaw fallback" }) satisfies ReplyPayload); + + await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + OriginatingTo: "discord:channel:no-handler", + To: "discord:channel:no-handler", + AccountId: "default", + MessageSid: "msg-no-handler-1", + SessionKey: "agent:main:discord:channel:no-handler", + CommandBody: "hello", + RawBody: "hello", + Body: "hello", + }), + cfg: emptyConfig, + dispatcher, + replyResolver, + }); + + const notice = (dispatcher.sendToolResult as ReturnType).mock.calls[0]?.[0] as + | ReplyPayload + | undefined; + expect(notice?.text).toContain("Routing this message to OpenClaw instead."); + expect(replyResolver).toHaveBeenCalledTimes(1); + expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled(); + }); + + it("notifies the user when a bound plugin declines the turn and keeps the binding attached", async () => { + setNoAbort(); + hookMocks.runner.hasHooks.mockImplementation( + ((hookName?: string) => + hookName === "inbound_claim" || hookName === "message_received") as () => boolean, + ); + hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }]; + hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({ + status: "declined", + }); + sessionBindingMocks.resolveByConversation.mockReturnValue({ + bindingId: "binding-declined-1", + targetSessionKey: "plugin-binding:codex:declined123", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:declined", + }, + status: "active", + boundAt: 1710000000000, + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + pluginName: "Codex App Server", + pluginRoot: "/Users/huntharo/github/openclaw-app-server", + detachHint: "/codex_detach", + }, + } satisfies SessionBindingRecord); + const dispatcher = createDispatcher(); + const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload); + + await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + OriginatingTo: "discord:channel:declined", + To: "discord:channel:declined", + AccountId: "default", + MessageSid: "msg-declined-1", + SessionKey: "agent:main:discord:channel:declined", + CommandBody: "hello", + RawBody: "hello", + Body: "hello", + }), + cfg: emptyConfig, + dispatcher, + replyResolver, + }); + + const finalNotice = (dispatcher.sendFinalReply as ReturnType).mock + .calls[0]?.[0] as ReplyPayload | undefined; + expect(finalNotice?.text).toContain("did not handle this message"); + expect(finalNotice?.text).toContain("/codex_detach"); + expect(replyResolver).not.toHaveBeenCalled(); + expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled(); + }); + + it("notifies the user when a bound plugin errors and keeps raw details out of the reply", async () => { + setNoAbort(); + hookMocks.runner.hasHooks.mockImplementation( + ((hookName?: string) => + hookName === "inbound_claim" || hookName === "message_received") as () => boolean, + ); + hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }]; + hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({ + status: "error", + error: "boom", + }); + sessionBindingMocks.resolveByConversation.mockReturnValue({ + bindingId: "binding-error-1", + targetSessionKey: "plugin-binding:codex:error123", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:error", + }, + status: "active", + boundAt: 1710000000000, + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + pluginName: "Codex App Server", + pluginRoot: "/Users/huntharo/github/openclaw-app-server", + }, + } satisfies SessionBindingRecord); + const dispatcher = createDispatcher(); + const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload); + + await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + OriginatingTo: "discord:channel:error", + To: "discord:channel:error", + AccountId: "default", + MessageSid: "msg-error-1", + SessionKey: "agent:main:discord:channel:error", + CommandBody: "hello", + RawBody: "hello", + Body: "hello", + }), + cfg: emptyConfig, + dispatcher, + replyResolver, + }); + + const finalNotice = (dispatcher.sendFinalReply as ReturnType).mock + .calls[0]?.[0] as ReplyPayload | undefined; + expect(finalNotice?.text).toContain("hit an error handling this message"); + expect(finalNotice?.text).not.toContain("boom"); + expect(replyResolver).not.toHaveBeenCalled(); + expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled(); + }); + it("marks diagnostics skipped for duplicate inbound messages", async () => { setNoAbort(); const cfg = { diagnostics: { enabled: true } } as OpenClawConfig; diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 5b679fa59e5..1e90dd58887 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -13,17 +13,29 @@ import { fireAndForgetHook } from "../../hooks/fire-and-forget.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; import { deriveInboundMessageHookContext, + toPluginInboundClaimContext, + toPluginInboundClaimEvent, toInternalMessageReceivedContext, toPluginMessageContext, toPluginMessageReceivedEvent, } from "../../hooks/message-hook-mappers.js"; import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js"; +import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; import { logMessageProcessed, logMessageQueued, logSessionStateChange, } from "../../logging/diagnostic.js"; -import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; +import { + buildPluginBindingDeclinedText, + buildPluginBindingErrorText, + buildPluginBindingUnavailableText, + hasShownPluginBindingFallbackNotice, + isPluginOwnedSessionBindingRecord, + markPluginBindingFallbackNoticeShown, + toPluginConversationBinding, +} from "../../plugins/conversation-binding.js"; +import { getGlobalHookRunner, getGlobalPluginRegistry } from "../../plugins/hook-runner-global.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { maybeApplyTtsToPayload, normalizeTtsAutoMode, resolveTtsConfig } from "../../tts/tts.js"; import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; @@ -190,30 +202,12 @@ export async function dispatchReplyFromConfig(params: { ctx.MessageSidFull ?? ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast; const hookContext = deriveInboundMessageHookContext(ctx, { messageId: messageIdForHook }); const { isGroup, groupId } = hookContext; - - // Trigger plugin hooks (fire-and-forget) - if (hookRunner?.hasHooks("message_received")) { - fireAndForgetHook( - hookRunner.runMessageReceived( - toPluginMessageReceivedEvent(hookContext), - toPluginMessageContext(hookContext), - ), - "dispatch-from-config: message_received plugin hook failed", - ); - } - - // Bridge to internal hooks (HOOK.md discovery system) - refs #8807 - if (sessionKey) { - fireAndForgetHook( - triggerInternalHook( - createInternalHookEvent("message", "received", sessionKey, { - ...toInternalMessageReceivedContext(hookContext), - timestamp, - }), - ), - "dispatch-from-config: message_received internal hook failed", - ); - } + const inboundClaimContext = toPluginInboundClaimContext(hookContext); + const inboundClaimEvent = toPluginInboundClaimEvent(hookContext, { + commandAuthorized: + typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : undefined, + wasMentioned: typeof ctx.WasMentioned === "boolean" ? ctx.WasMentioned : undefined, + }); // Check if we should route replies to originating channel instead of dispatcher. // Only route when the originating channel is DIFFERENT from the current surface. @@ -279,6 +273,144 @@ export async function dispatchReplyFromConfig(params: { } }; + const sendBindingNotice = async ( + payload: ReplyPayload, + mode: "additive" | "terminal", + ): Promise => { + if (shouldRouteToOriginating && originatingChannel && originatingTo) { + const result = await routeReply({ + payload, + channel: originatingChannel, + to: originatingTo, + sessionKey: ctx.SessionKey, + accountId: ctx.AccountId, + threadId: routeThreadId, + cfg, + isGroup, + groupId, + }); + if (!result.ok) { + logVerbose( + `dispatch-from-config: route-reply (plugin binding notice) failed: ${result.error ?? "unknown error"}`, + ); + } + return result.ok; + } + return mode === "additive" + ? dispatcher.sendToolResult(payload) + : dispatcher.sendFinalReply(payload); + }; + + const pluginOwnedBindingRecord = + inboundClaimContext.conversationId && inboundClaimContext.channelId + ? getSessionBindingService().resolveByConversation({ + channel: inboundClaimContext.channelId, + accountId: inboundClaimContext.accountId ?? "default", + conversationId: inboundClaimContext.conversationId, + parentConversationId: inboundClaimContext.parentConversationId, + }) + : null; + const pluginOwnedBinding = isPluginOwnedSessionBindingRecord(pluginOwnedBindingRecord) + ? toPluginConversationBinding(pluginOwnedBindingRecord) + : null; + + let pluginFallbackReason: + | "plugin-bound-fallback-missing-plugin" + | "plugin-bound-fallback-no-handler" + | undefined; + + if (pluginOwnedBinding) { + getSessionBindingService().touch(pluginOwnedBinding.bindingId); + logVerbose( + `plugin-bound inbound routed to ${pluginOwnedBinding.pluginId} conversation=${pluginOwnedBinding.conversationId}`, + ); + const targetedClaimOutcome = hookRunner?.runInboundClaimForPluginOutcome + ? await hookRunner.runInboundClaimForPluginOutcome( + pluginOwnedBinding.pluginId, + inboundClaimEvent, + inboundClaimContext, + ) + : (() => { + const pluginLoaded = + getGlobalPluginRegistry()?.plugins.some( + (plugin) => plugin.id === pluginOwnedBinding.pluginId && plugin.status === "loaded", + ) ?? false; + return pluginLoaded + ? ({ status: "no_handler" } as const) + : ({ status: "missing_plugin" } as const); + })(); + + switch (targetedClaimOutcome.status) { + case "handled": { + markIdle("plugin_binding_dispatch"); + recordProcessed("completed", { reason: "plugin-bound-handled" }); + return { queuedFinal: false, counts: dispatcher.getQueuedCounts() }; + } + case "missing_plugin": + case "no_handler": { + pluginFallbackReason = + targetedClaimOutcome.status === "missing_plugin" + ? "plugin-bound-fallback-missing-plugin" + : "plugin-bound-fallback-no-handler"; + if (!hasShownPluginBindingFallbackNotice(pluginOwnedBinding.bindingId)) { + const didSendNotice = await sendBindingNotice( + { text: buildPluginBindingUnavailableText(pluginOwnedBinding) }, + "additive", + ); + if (didSendNotice) { + markPluginBindingFallbackNoticeShown(pluginOwnedBinding.bindingId); + } + } + break; + } + case "declined": { + await sendBindingNotice( + { text: buildPluginBindingDeclinedText(pluginOwnedBinding) }, + "terminal", + ); + markIdle("plugin_binding_declined"); + recordProcessed("completed", { reason: "plugin-bound-declined" }); + return { queuedFinal: false, counts: dispatcher.getQueuedCounts() }; + } + case "error": { + logVerbose( + `plugin-bound inbound claim failed for ${pluginOwnedBinding.pluginId}: ${targetedClaimOutcome.error}`, + ); + await sendBindingNotice( + { text: buildPluginBindingErrorText(pluginOwnedBinding) }, + "terminal", + ); + markIdle("plugin_binding_error"); + recordProcessed("completed", { reason: "plugin-bound-error" }); + return { queuedFinal: false, counts: dispatcher.getQueuedCounts() }; + } + } + } + + // Trigger plugin hooks (fire-and-forget) + if (hookRunner?.hasHooks("message_received")) { + fireAndForgetHook( + hookRunner.runMessageReceived( + toPluginMessageReceivedEvent(hookContext), + toPluginMessageContext(hookContext), + ), + "dispatch-from-config: message_received plugin hook failed", + ); + } + + // Bridge to internal hooks (HOOK.md discovery system) - refs #8807 + if (sessionKey) { + fireAndForgetHook( + triggerInternalHook( + createInternalHookEvent("message", "received", sessionKey, { + ...toInternalMessageReceivedContext(hookContext), + timestamp, + }), + ), + "dispatch-from-config: message_received internal hook failed", + ); + } + markProcessing(); try { @@ -606,7 +738,10 @@ export async function dispatchReplyFromConfig(params: { const counts = dispatcher.getQueuedCounts(); counts.final += routedFinalCount; - recordProcessed("completed"); + recordProcessed( + "completed", + pluginFallbackReason ? { reason: pluginFallbackReason } : undefined, + ); markIdle("message_completed"); return { queuedFinal, counts }; } catch (err) { diff --git a/src/hooks/message-hook-mappers.test.ts b/src/hooks/message-hook-mappers.test.ts index c365f463ade..53660054a15 100644 --- a/src/hooks/message-hook-mappers.test.ts +++ b/src/hooks/message-hook-mappers.test.ts @@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { buildCanonicalSentMessageHookContext, deriveInboundMessageHookContext, + toPluginInboundClaimContext, toInternalMessagePreprocessedContext, toInternalMessageReceivedContext, toInternalMessageSentContext, @@ -99,6 +100,53 @@ describe("message hook mappers", () => { }); }); + it("normalizes Discord channel targets for inbound claim contexts", () => { + const canonical = deriveInboundMessageHookContext( + makeInboundCtx({ + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + To: "channel:123456789012345678", + OriginatingTo: "channel:123456789012345678", + GroupChannel: "general", + GroupSubject: "guild", + }), + ); + + expect(toPluginInboundClaimContext(canonical)).toEqual({ + channelId: "discord", + accountId: "acc-1", + conversationId: "channel:123456789012345678", + parentConversationId: undefined, + senderId: "sender-1", + messageId: "msg-1", + }); + }); + + it("normalizes Discord DM targets for inbound claim contexts", () => { + const canonical = deriveInboundMessageHookContext( + makeInboundCtx({ + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + From: "discord:1177378744822943744", + To: "channel:1480574946919846079", + OriginatingTo: "channel:1480574946919846079", + GroupChannel: undefined, + GroupSubject: undefined, + }), + ); + + expect(toPluginInboundClaimContext(canonical)).toEqual({ + channelId: "discord", + accountId: "acc-1", + conversationId: "user:1177378744822943744", + parentConversationId: undefined, + senderId: "sender-1", + messageId: "msg-1", + }); + }); + it("maps transcribed and preprocessed internal payloads", () => { const cfg = {} as OpenClawConfig; const canonical = deriveInboundMessageHookContext(makeInboundCtx({ Transcript: undefined })); diff --git a/src/hooks/message-hook-mappers.ts b/src/hooks/message-hook-mappers.ts index 1cdd12a93ac..968a4d50719 100644 --- a/src/hooks/message-hook-mappers.ts +++ b/src/hooks/message-hook-mappers.ts @@ -1,6 +1,8 @@ import type { FinalizedMsgContext } from "../auto-reply/templating.js"; import type { OpenClawConfig } from "../config/config.js"; import type { + PluginHookInboundClaimContext, + PluginHookInboundClaimEvent, PluginHookMessageContext, PluginHookMessageReceivedEvent, PluginHookMessageSentEvent, @@ -147,6 +149,136 @@ export function toPluginMessageContext( }; } +function stripChannelPrefix(value: string | undefined, channelId: string): string | undefined { + if (!value) { + return undefined; + } + const genericPrefixes = ["channel:", "chat:", "user:"]; + for (const prefix of genericPrefixes) { + if (value.startsWith(prefix)) { + return value.slice(prefix.length); + } + } + const prefix = `${channelId}:`; + return value.startsWith(prefix) ? value.slice(prefix.length) : value; +} + +function deriveParentConversationId( + canonical: CanonicalInboundMessageHookContext, +): string | undefined { + if (canonical.channelId !== "telegram") { + return undefined; + } + if (typeof canonical.threadId !== "number" && typeof canonical.threadId !== "string") { + return undefined; + } + return stripChannelPrefix( + canonical.to ?? canonical.originatingTo ?? canonical.conversationId, + "telegram", + ); +} + +function deriveConversationId(canonical: CanonicalInboundMessageHookContext): string | undefined { + if (canonical.channelId === "discord") { + const rawTarget = canonical.to ?? canonical.originatingTo ?? canonical.conversationId; + const rawSender = canonical.from; + const senderUserId = rawSender?.startsWith("discord:user:") + ? rawSender.slice("discord:user:".length) + : rawSender?.startsWith("discord:") + ? rawSender.slice("discord:".length) + : undefined; + if (!canonical.isGroup && senderUserId) { + return `user:${senderUserId}`; + } + if (!rawTarget) { + return undefined; + } + if (rawTarget.startsWith("discord:channel:")) { + return `channel:${rawTarget.slice("discord:channel:".length)}`; + } + if (rawTarget.startsWith("discord:user:")) { + return `user:${rawTarget.slice("discord:user:".length)}`; + } + if (rawTarget.startsWith("discord:")) { + return `user:${rawTarget.slice("discord:".length)}`; + } + if (rawTarget.startsWith("channel:") || rawTarget.startsWith("user:")) { + return rawTarget; + } + } + const baseConversationId = stripChannelPrefix( + canonical.to ?? canonical.originatingTo ?? canonical.conversationId, + canonical.channelId, + ); + if (canonical.channelId === "telegram" && baseConversationId) { + const threadId = + typeof canonical.threadId === "number" || typeof canonical.threadId === "string" + ? String(canonical.threadId).trim() + : ""; + if (threadId) { + return `${baseConversationId}:topic:${threadId}`; + } + } + return baseConversationId; +} + +export function toPluginInboundClaimContext( + canonical: CanonicalInboundMessageHookContext, +): PluginHookInboundClaimContext { + const conversationId = deriveConversationId(canonical); + return { + channelId: canonical.channelId, + accountId: canonical.accountId, + conversationId, + parentConversationId: deriveParentConversationId(canonical), + senderId: canonical.senderId, + messageId: canonical.messageId, + }; +} + +export function toPluginInboundClaimEvent( + canonical: CanonicalInboundMessageHookContext, + extras?: { + commandAuthorized?: boolean; + wasMentioned?: boolean; + }, +): PluginHookInboundClaimEvent { + const context = toPluginInboundClaimContext(canonical); + return { + content: canonical.content, + body: canonical.body, + bodyForAgent: canonical.bodyForAgent, + transcript: canonical.transcript, + timestamp: canonical.timestamp, + channel: canonical.channelId, + accountId: canonical.accountId, + conversationId: context.conversationId, + parentConversationId: context.parentConversationId, + senderId: canonical.senderId, + senderName: canonical.senderName, + senderUsername: canonical.senderUsername, + threadId: canonical.threadId, + messageId: canonical.messageId, + isGroup: canonical.isGroup, + commandAuthorized: extras?.commandAuthorized, + wasMentioned: extras?.wasMentioned, + metadata: { + from: canonical.from, + to: canonical.to, + provider: canonical.provider, + surface: canonical.surface, + originatingChannel: canonical.originatingChannel, + originatingTo: canonical.originatingTo, + senderE164: canonical.senderE164, + mediaPath: canonical.mediaPath, + mediaType: canonical.mediaType, + guildId: canonical.guildId, + channelName: canonical.channelName, + groupId: canonical.groupId, + }, + }; +} + export function toPluginMessageReceivedEvent( canonical: CanonicalInboundMessageHookContext, ): PluginHookMessageReceivedEvent { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 8b4a4f28a4e..308c63e2920 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -100,6 +100,12 @@ export type { OpenClawPluginApi, OpenClawPluginService, OpenClawPluginServiceContext, + PluginHookInboundClaimContext, + PluginHookInboundClaimEvent, + PluginHookInboundClaimResult, + PluginInteractiveDiscordHandlerContext, + PluginInteractiveHandlerRegistration, + PluginInteractiveTelegramHandlerContext, PluginLogger, ProviderAuthContext, ProviderAuthResult, @@ -113,6 +119,14 @@ export type { ProviderRuntimeModel, ProviderWrapStreamFnContext, } from "../plugins/types.js"; +export type { + ConversationRef, + SessionBindingBindInput, + SessionBindingCapabilities, + SessionBindingRecord, + SessionBindingService, + SessionBindingUnbindInput, +} from "../infra/outbound/session-binding-service.js"; export type { GatewayRequestHandler, GatewayRequestHandlerOptions, diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 34d411702a0..64f953fb014 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, it } from "vitest"; import { + __testing, clearPluginCommands, + executePluginCommand, getPluginCommandSpecs, listPluginCommands, registerPluginCommand, @@ -93,5 +95,107 @@ describe("registerPluginCommand", () => { acceptsArgs: false, }, ]); + expect(getPluginCommandSpecs("slack")).toEqual([]); + }); + + it("resolves Discord DM command bindings with the user target prefix intact", () => { + expect( + __testing.resolveBindingConversationFromCommand({ + channel: "discord", + from: "discord:1177378744822943744", + to: "slash:1177378744822943744", + accountId: "default", + }), + ).toEqual({ + channel: "discord", + accountId: "default", + conversationId: "user:1177378744822943744", + }); + }); + + it("resolves Discord guild command bindings with the channel target prefix intact", () => { + expect( + __testing.resolveBindingConversationFromCommand({ + channel: "discord", + from: "discord:channel:1480554272859881494", + accountId: "default", + }), + ).toEqual({ + channel: "discord", + accountId: "default", + conversationId: "channel:1480554272859881494", + }); + }); + + it("does not resolve binding conversations for unsupported command channels", () => { + expect( + __testing.resolveBindingConversationFromCommand({ + channel: "slack", + from: "slack:U123", + to: "C456", + accountId: "default", + }), + ).toBeNull(); + }); + + it("does not expose binding APIs to plugin commands on unsupported channels", async () => { + const handler = async (ctx: { + requestConversationBinding: (params: { summary: string }) => Promise; + getCurrentConversationBinding: () => Promise; + detachConversationBinding: () => Promise; + }) => { + const requested = await ctx.requestConversationBinding({ + summary: "Bind this conversation.", + }); + const current = await ctx.getCurrentConversationBinding(); + const detached = await ctx.detachConversationBinding(); + return { + text: JSON.stringify({ + requested, + current, + detached, + }), + }; + }; + registerPluginCommand( + "demo-plugin", + { + name: "bindcheck", + description: "Demo command", + acceptsArgs: false, + handler, + }, + { pluginRoot: "/plugins/demo-plugin" }, + ); + + const result = await executePluginCommand({ + command: { + name: "bindcheck", + description: "Demo command", + acceptsArgs: false, + handler, + pluginId: "demo-plugin", + pluginRoot: "/plugins/demo-plugin", + }, + channel: "slack", + senderId: "U123", + isAuthorizedSender: true, + commandBody: "/bindcheck", + config: {} as never, + from: "slack:U123", + to: "C456", + accountId: "default", + }); + + expect(result.text).toBe( + JSON.stringify({ + requested: { + status: "error", + message: "This command cannot bind the current conversation.", + }, + current: null, + detached: { removed: false }, + }), + ); }); }); diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index 00e4b3b34ae..6bc049ff626 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -5,8 +5,15 @@ * These commands are processed before built-in commands and before agent invocation. */ +import { parseDiscordTarget } from "../../extensions/discord/src/targets.js"; +import { parseTelegramTarget } from "../../extensions/telegram/src/targets.js"; import type { OpenClawConfig } from "../config/config.js"; import { logVerbose } from "../globals.js"; +import { + detachPluginConversationBinding, + getCurrentPluginConversationBinding, + requestPluginConversationBinding, +} from "./conversation-binding.js"; import type { OpenClawPluginCommandDefinition, PluginCommandContext, @@ -15,6 +22,8 @@ import type { type RegisteredPluginCommand = OpenClawPluginCommandDefinition & { pluginId: string; + pluginName?: string; + pluginRoot?: string; }; // Registry of plugin commands @@ -109,6 +118,7 @@ export type CommandRegistrationResult = { export function registerPluginCommand( pluginId: string, command: OpenClawPluginCommandDefinition, + opts?: { pluginName?: string; pluginRoot?: string }, ): CommandRegistrationResult { // Prevent registration while commands are being processed if (registryLocked) { @@ -149,7 +159,14 @@ export function registerPluginCommand( }; } - pluginCommands.set(key, { ...command, name, description, pluginId }); + pluginCommands.set(key, { + ...command, + name, + description, + pluginId, + pluginName: opts?.pluginName, + pluginRoot: opts?.pluginRoot, + }); logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`); return { ok: true }; } @@ -235,6 +252,63 @@ function sanitizeArgs(args: string | undefined): string | undefined { return sanitized; } +function stripPrefix(raw: string | undefined, prefix: string): string | undefined { + if (!raw) { + return undefined; + } + return raw.startsWith(prefix) ? raw.slice(prefix.length) : raw; +} + +function resolveBindingConversationFromCommand(params: { + channel: string; + from?: string; + to?: string; + accountId?: string; + messageThreadId?: number; +}): { + channel: string; + accountId: string; + conversationId: string; + parentConversationId?: string; + threadId?: string | number; +} | null { + const accountId = params.accountId?.trim() || "default"; + if (params.channel === "telegram") { + const rawTarget = params.to ?? params.from; + if (!rawTarget) { + return null; + } + const target = parseTelegramTarget(rawTarget); + return { + channel: "telegram", + accountId, + conversationId: target.chatId, + threadId: params.messageThreadId ?? target.messageThreadId, + }; + } + if (params.channel === "discord") { + const source = params.from ?? params.to; + const rawTarget = source?.startsWith("discord:channel:") + ? stripPrefix(source, "discord:") + : source?.startsWith("discord:user:") + ? stripPrefix(source, "discord:") + : source; + if (!rawTarget || rawTarget.startsWith("slash:")) { + return null; + } + const target = parseDiscordTarget(rawTarget, { defaultKind: "channel" }); + if (!target) { + return null; + } + return { + channel: "discord", + accountId, + conversationId: `${target.kind}:${target.id}`, + }; + } + return null; +} + /** * Execute a plugin command handler. * @@ -268,6 +342,13 @@ export async function executePluginCommand(params: { // Sanitize args before passing to handler const sanitizedArgs = sanitizeArgs(args); + const bindingConversation = resolveBindingConversationFromCommand({ + channel, + from: params.from, + to: params.to, + accountId: params.accountId, + messageThreadId: params.messageThreadId, + }); const ctx: PluginCommandContext = { senderId, @@ -281,6 +362,40 @@ export async function executePluginCommand(params: { to: params.to, accountId: params.accountId, messageThreadId: params.messageThreadId, + requestConversationBinding: async (bindingParams) => { + if (!command.pluginRoot || !bindingConversation) { + return { + status: "error", + message: "This command cannot bind the current conversation.", + }; + } + return requestPluginConversationBinding({ + pluginId: command.pluginId, + pluginName: command.pluginName, + pluginRoot: command.pluginRoot, + requestedBySenderId: senderId, + conversation: bindingConversation, + binding: bindingParams, + }); + }, + detachConversationBinding: async () => { + if (!command.pluginRoot || !bindingConversation) { + return { removed: false }; + } + return detachPluginConversationBinding({ + pluginRoot: command.pluginRoot, + conversation: bindingConversation, + }); + }, + getCurrentConversationBinding: async () => { + if (!command.pluginRoot || !bindingConversation) { + return null; + } + return getCurrentPluginConversationBinding({ + pluginRoot: command.pluginRoot, + conversation: bindingConversation, + }); + }, }; // Lock registry during execution to prevent concurrent modifications @@ -341,9 +456,17 @@ export function getPluginCommandSpecs(provider?: string): Array<{ description: string; acceptsArgs: boolean; }> { + const providerName = provider?.trim().toLowerCase(); + if (providerName && providerName !== "telegram" && providerName !== "discord") { + return []; + } return Array.from(pluginCommands.values()).map((cmd) => ({ name: resolvePluginNativeName(cmd, provider), description: cmd.description, acceptsArgs: cmd.acceptsArgs ?? false, })); } + +export const __testing = { + resolveBindingConversationFromCommand, +}; diff --git a/src/plugins/conversation-binding.test.ts b/src/plugins/conversation-binding.test.ts new file mode 100644 index 00000000000..821fd9e3b48 --- /dev/null +++ b/src/plugins/conversation-binding.test.ts @@ -0,0 +1,575 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + ConversationRef, + SessionBindingAdapter, + SessionBindingRecord, +} from "../infra/outbound/session-binding-service.js"; + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-binding-")); +const approvalsPath = path.join(tempRoot, "plugin-binding-approvals.json"); + +const sessionBindingState = vi.hoisted(() => { + const records = new Map(); + let nextId = 1; + + function normalizeRef(ref: ConversationRef): ConversationRef { + return { + channel: ref.channel.trim().toLowerCase(), + accountId: ref.accountId.trim() || "default", + conversationId: ref.conversationId.trim(), + parentConversationId: ref.parentConversationId?.trim() || undefined, + }; + } + + function toKey(ref: ConversationRef): string { + const normalized = normalizeRef(ref); + return JSON.stringify(normalized); + } + + return { + records, + bind: vi.fn( + async (input: { + targetSessionKey: string; + targetKind: "session" | "subagent"; + conversation: ConversationRef; + metadata?: Record; + }) => { + const normalized = normalizeRef(input.conversation); + const record: SessionBindingRecord = { + bindingId: `binding-${nextId++}`, + targetSessionKey: input.targetSessionKey, + targetKind: input.targetKind, + conversation: normalized, + status: "active", + boundAt: Date.now(), + metadata: input.metadata, + }; + records.set(toKey(normalized), record); + return record; + }, + ), + resolveByConversation: vi.fn((ref: ConversationRef) => { + return records.get(toKey(ref)) ?? null; + }), + touch: vi.fn(), + unbind: vi.fn(async (input: { bindingId?: string }) => { + const removed: SessionBindingRecord[] = []; + for (const [key, record] of records.entries()) { + if (record.bindingId !== input.bindingId) { + continue; + } + removed.push(record); + records.delete(key); + } + return removed; + }), + reset() { + records.clear(); + nextId = 1; + this.bind.mockClear(); + this.resolveByConversation.mockClear(); + this.touch.mockClear(); + this.unbind.mockClear(); + }, + setRecord(record: SessionBindingRecord) { + records.set(toKey(record.conversation), record); + }, + }; +}); + +vi.mock("../infra/home-dir.js", () => ({ + expandHomePrefix: (value: string) => { + if (value === "~/.openclaw/plugin-binding-approvals.json") { + return approvalsPath; + } + return value; + }, +})); + +const { + __testing, + buildPluginBindingApprovalCustomId, + detachPluginConversationBinding, + getCurrentPluginConversationBinding, + parsePluginBindingApprovalCustomId, + requestPluginConversationBinding, + resolvePluginConversationBindingApproval, +} = await import("./conversation-binding.js"); +const { registerSessionBindingAdapter, unregisterSessionBindingAdapter } = + await import("../infra/outbound/session-binding-service.js"); + +function createAdapter(channel: string, accountId: string): SessionBindingAdapter { + return { + channel, + accountId, + capabilities: { + bindSupported: true, + unbindSupported: true, + placements: ["current", "child"], + }, + bind: sessionBindingState.bind, + listBySession: () => [], + resolveByConversation: sessionBindingState.resolveByConversation, + touch: sessionBindingState.touch, + unbind: sessionBindingState.unbind, + }; +} + +describe("plugin conversation binding approvals", () => { + beforeEach(() => { + sessionBindingState.reset(); + __testing.reset(); + fs.rmSync(approvalsPath, { force: true }); + unregisterSessionBindingAdapter({ channel: "discord", accountId: "default" }); + unregisterSessionBindingAdapter({ channel: "discord", accountId: "work" }); + unregisterSessionBindingAdapter({ channel: "discord", accountId: "isolated" }); + unregisterSessionBindingAdapter({ channel: "telegram", accountId: "default" }); + registerSessionBindingAdapter(createAdapter("discord", "default")); + registerSessionBindingAdapter(createAdapter("discord", "work")); + registerSessionBindingAdapter(createAdapter("discord", "isolated")); + registerSessionBindingAdapter(createAdapter("telegram", "default")); + }); + + it("keeps Telegram bind approval callback_data within Telegram's limit", () => { + const allowOnce = buildPluginBindingApprovalCustomId("abcdefghijkl", "allow-once"); + const allowAlways = buildPluginBindingApprovalCustomId("abcdefghijkl", "allow-always"); + const deny = buildPluginBindingApprovalCustomId("abcdefghijkl", "deny"); + + expect(Buffer.byteLength(allowOnce, "utf8")).toBeLessThanOrEqual(64); + expect(Buffer.byteLength(allowAlways, "utf8")).toBeLessThanOrEqual(64); + expect(Buffer.byteLength(deny, "utf8")).toBeLessThanOrEqual(64); + expect(parsePluginBindingApprovalCustomId(allowAlways)).toEqual({ + approvalId: "abcdefghijkl", + decision: "allow-always", + }); + }); + + it("requires a fresh approval again after allow-once is consumed", async () => { + const firstRequest = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:1", + }, + binding: { summary: "Bind this conversation to Codex thread 123." }, + }); + + expect(firstRequest.status).toBe("pending"); + if (firstRequest.status !== "pending") { + throw new Error("expected pending bind request"); + } + + const approved = await resolvePluginConversationBindingApproval({ + approvalId: firstRequest.approvalId, + decision: "allow-once", + senderId: "user-1", + }); + + expect(approved.status).toBe("approved"); + + const secondRequest = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:2", + }, + binding: { summary: "Bind this conversation to Codex thread 456." }, + }); + + expect(secondRequest.status).toBe("pending"); + }); + + it("persists always-allow by plugin root plus channel/account only", async () => { + const firstRequest = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:1", + }, + binding: { summary: "Bind this conversation to Codex thread 123." }, + }); + + expect(firstRequest.status).toBe("pending"); + if (firstRequest.status !== "pending") { + throw new Error("expected pending bind request"); + } + + const approved = await resolvePluginConversationBindingApproval({ + approvalId: firstRequest.approvalId, + decision: "allow-always", + senderId: "user-1", + }); + + expect(approved.status).toBe("approved"); + + const sameScope = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:2", + }, + binding: { summary: "Bind this conversation to Codex thread 456." }, + }); + + expect(sameScope.status).toBe("bound"); + + const differentAccount = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "work", + conversationId: "channel:3", + }, + binding: { summary: "Bind this conversation to Codex thread 789." }, + }); + + expect(differentAccount.status).toBe("pending"); + }); + + it("does not share persistent approvals across plugin roots even with the same plugin id", async () => { + const request = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + threadId: "77", + }, + binding: { summary: "Bind this conversation to Codex thread abc." }, + }); + + expect(request.status).toBe("pending"); + if (request.status !== "pending") { + throw new Error("expected pending bind request"); + } + + await resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "allow-always", + senderId: "user-1", + }); + + const samePluginNewPath = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-b", + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:78", + parentConversationId: "-10099", + threadId: "78", + }, + binding: { summary: "Bind this conversation to Codex thread def." }, + }); + + expect(samePluginNewPath.status).toBe("pending"); + }); + + it("persists detachHint on approved plugin bindings", async () => { + const request = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:detach-hint", + }, + binding: { + summary: "Bind this conversation to Codex thread 999.", + detachHint: "/codex_detach", + }, + }); + + expect(["pending", "bound"]).toContain(request.status); + + if (request.status === "pending") { + const approved = await resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "allow-once", + senderId: "user-1", + }); + + expect(approved.status).toBe("approved"); + if (approved.status !== "approved") { + throw new Error("expected approved bind request"); + } + + expect(approved.binding.detachHint).toBe("/codex_detach"); + } else { + expect(request.binding.detachHint).toBe("/codex_detach"); + } + + const currentBinding = await getCurrentPluginConversationBinding({ + pluginRoot: "/plugins/codex-a", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:detach-hint", + }, + }); + + expect(currentBinding?.detachHint).toBe("/codex_detach"); + }); + + it("returns and detaches only bindings owned by the requesting plugin root", async () => { + const request = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:1", + }, + binding: { summary: "Bind this conversation to Codex thread 123." }, + }); + + expect(["pending", "bound"]).toContain(request.status); + if (request.status === "pending") { + await resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "allow-once", + senderId: "user-1", + }); + } + + const current = await getCurrentPluginConversationBinding({ + pluginRoot: "/plugins/codex-a", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:1", + }, + }); + + expect(current).toEqual( + expect.objectContaining({ + pluginId: "codex", + pluginRoot: "/plugins/codex-a", + conversationId: "channel:1", + }), + ); + + const otherPluginView = await getCurrentPluginConversationBinding({ + pluginRoot: "/plugins/codex-b", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:1", + }, + }); + + expect(otherPluginView).toBeNull(); + + expect( + await detachPluginConversationBinding({ + pluginRoot: "/plugins/codex-b", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:1", + }, + }), + ).toEqual({ removed: false }); + + expect( + await detachPluginConversationBinding({ + pluginRoot: "/plugins/codex-a", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:1", + }, + }), + ).toEqual({ removed: true }); + }); + + it("refuses to claim a conversation already bound by core", async () => { + sessionBindingState.setRecord({ + bindingId: "binding-core", + targetSessionKey: "agent:main:discord:channel:1", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:1", + }, + status: "active", + boundAt: Date.now(), + metadata: { owner: "core" }, + }); + + const result = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:1", + }, + binding: { summary: "Bind this conversation to Codex thread 123." }, + }); + + expect(result).toEqual({ + status: "error", + message: + "This conversation is already bound by core routing and cannot be claimed by a plugin.", + }); + }); + + it("migrates a legacy plugin binding record through the new approval flow even if the old plugin id differs", async () => { + sessionBindingState.setRecord({ + bindingId: "binding-legacy", + targetSessionKey: "plugin-binding:old-codex-plugin:legacy123", + targetKind: "session", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + }, + status: "active", + boundAt: Date.now(), + metadata: { + label: "legacy plugin bind", + }, + }); + + const request = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + threadId: "77", + }, + binding: { summary: "Bind this conversation to Codex thread abc." }, + }); + + expect(["pending", "bound"]).toContain(request.status); + const binding = + request.status === "pending" + ? await resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "allow-once", + senderId: "user-1", + }).then((approved) => { + expect(approved.status).toBe("approved"); + if (approved.status !== "approved") { + throw new Error("expected approved bind result"); + } + return approved.binding; + }) + : request.status === "bound" + ? request.binding + : (() => { + throw new Error("expected pending or bound bind result"); + })(); + + expect(binding).toEqual( + expect.objectContaining({ + pluginId: "codex", + pluginRoot: "/plugins/codex-a", + conversationId: "-10099:topic:77", + }), + ); + }); + + it("migrates a legacy codex thread binding session key through the new approval flow", async () => { + sessionBindingState.setRecord({ + bindingId: "binding-legacy-codex-thread", + targetSessionKey: "openclaw-app-server:thread:019ce411-6322-7db2-a821-1a61c530e7d9", + targetKind: "session", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "8460800771", + }, + status: "active", + boundAt: Date.now(), + metadata: { + label: "legacy codex thread bind", + }, + }); + + const request = await requestPluginConversationBinding({ + pluginId: "openclaw-codex-app-server", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "8460800771", + }, + binding: { + summary: "Bind this conversation to Codex thread 019ce411-6322-7db2-a821-1a61c530e7d9.", + }, + }); + + expect(["pending", "bound"]).toContain(request.status); + const binding = + request.status === "pending" + ? await resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "allow-once", + senderId: "user-1", + }).then((approved) => { + expect(approved.status).toBe("approved"); + if (approved.status !== "approved") { + throw new Error("expected approved bind result"); + } + return approved.binding; + }) + : request.status === "bound" + ? request.binding + : (() => { + throw new Error("expected pending or bound bind result"); + })(); + + expect(binding).toEqual( + expect.objectContaining({ + pluginId: "openclaw-codex-app-server", + pluginRoot: "/plugins/codex-a", + conversationId: "8460800771", + }), + ); + }); +}); diff --git a/src/plugins/conversation-binding.ts b/src/plugins/conversation-binding.ts new file mode 100644 index 00000000000..3de655abbe1 --- /dev/null +++ b/src/plugins/conversation-binding.ts @@ -0,0 +1,825 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { Button, Row, type TopLevelComponents } from "@buape/carbon"; +import { ButtonStyle } from "discord-api-types/v10"; +import type { ReplyPayload } from "../auto-reply/types.js"; +import { expandHomePrefix } from "../infra/home-dir.js"; +import { writeJsonAtomic } from "../infra/json-files.js"; +import { + getSessionBindingService, + type ConversationRef, +} from "../infra/outbound/session-binding-service.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import type { + PluginConversationBinding, + PluginConversationBindingRequestParams, + PluginConversationBindingRequestResult, +} from "./types.js"; + +const log = createSubsystemLogger("plugins/binding"); + +const APPROVALS_PATH = "~/.openclaw/plugin-binding-approvals.json"; +const PLUGIN_BINDING_CUSTOM_ID_PREFIX = "pluginbind"; +const PLUGIN_BINDING_OWNER = "plugin"; +const PLUGIN_BINDING_SESSION_PREFIX = "plugin-binding"; +const LEGACY_CODEX_PLUGIN_SESSION_PREFIXES = [ + "openclaw-app-server:thread:", + "openclaw-codex-app-server:thread:", +] as const; + +type PluginBindingApprovalDecision = "allow-once" | "allow-always" | "deny"; + +type PluginBindingApprovalEntry = { + pluginRoot: string; + pluginId: string; + pluginName?: string; + channel: string; + accountId: string; + approvedAt: number; +}; + +type PluginBindingApprovalsFile = { + version: 1; + approvals: PluginBindingApprovalEntry[]; +}; + +type PluginBindingConversation = { + channel: string; + accountId: string; + conversationId: string; + parentConversationId?: string; + threadId?: string | number; +}; + +type PendingPluginBindingRequest = { + id: string; + pluginId: string; + pluginName?: string; + pluginRoot: string; + conversation: PluginBindingConversation; + requestedAt: number; + requestedBySenderId?: string; + summary?: string; + detachHint?: string; +}; + +type PluginBindingApprovalAction = { + approvalId: string; + decision: PluginBindingApprovalDecision; +}; + +type PluginBindingIdentity = { + pluginId: string; + pluginName?: string; + pluginRoot: string; +}; + +type PluginBindingMetadata = { + pluginBindingOwner: "plugin"; + pluginId: string; + pluginName?: string; + pluginRoot: string; + summary?: string; + detachHint?: string; +}; + +type PluginBindingResolveResult = + | { + status: "approved"; + binding: PluginConversationBinding; + request: PendingPluginBindingRequest; + decision: PluginBindingApprovalDecision; + } + | { + status: "denied"; + request: PendingPluginBindingRequest; + } + | { + status: "expired"; + }; + +const pendingRequests = new Map(); + +type PluginBindingGlobalState = { + fallbackNoticeBindingIds: Set; +}; + +const pluginBindingGlobalStateKey = Symbol.for("openclaw.plugins.binding.global-state"); + +let approvalsCache: PluginBindingApprovalsFile | null = null; +let approvalsLoaded = false; + +function getPluginBindingGlobalState(): PluginBindingGlobalState { + const globalStore = globalThis as typeof globalThis & { + [pluginBindingGlobalStateKey]?: PluginBindingGlobalState; + }; + return (globalStore[pluginBindingGlobalStateKey] ??= { + fallbackNoticeBindingIds: new Set(), + }); +} + +class PluginBindingApprovalButton extends Button { + customId: string; + label: string; + style: ButtonStyle; + + constructor(params: { + approvalId: string; + decision: PluginBindingApprovalDecision; + label: string; + style: ButtonStyle; + }) { + super(); + this.customId = buildPluginBindingApprovalCustomId(params.approvalId, params.decision); + this.label = params.label; + this.style = params.style; + } +} + +function resolveApprovalsPath(): string { + return expandHomePrefix(APPROVALS_PATH); +} + +function normalizeChannel(value: string): string { + return value.trim().toLowerCase(); +} + +function normalizeConversation(params: PluginBindingConversation): PluginBindingConversation { + return { + channel: normalizeChannel(params.channel), + accountId: params.accountId.trim() || "default", + conversationId: params.conversationId.trim(), + parentConversationId: params.parentConversationId?.trim() || undefined, + threadId: + typeof params.threadId === "number" + ? Math.trunc(params.threadId) + : params.threadId?.toString().trim() || undefined, + }; +} + +function toConversationRef(params: PluginBindingConversation): ConversationRef { + const normalized = normalizeConversation(params); + if (normalized.channel === "telegram") { + const threadId = + typeof normalized.threadId === "number" || typeof normalized.threadId === "string" + ? String(normalized.threadId).trim() + : ""; + if (threadId) { + const parent = normalized.parentConversationId?.trim() || normalized.conversationId; + return { + channel: "telegram", + accountId: normalized.accountId, + conversationId: `${parent}:topic:${threadId}`, + }; + } + } + return { + channel: normalized.channel, + accountId: normalized.accountId, + conversationId: normalized.conversationId, + ...(normalized.parentConversationId + ? { parentConversationId: normalized.parentConversationId } + : {}), + }; +} + +function buildApprovalScopeKey(params: { + pluginRoot: string; + channel: string; + accountId: string; +}): string { + return [ + params.pluginRoot, + normalizeChannel(params.channel), + params.accountId.trim() || "default", + ].join("::"); +} + +function buildPluginBindingSessionKey(params: { + pluginId: string; + channel: string; + accountId: string; + conversationId: string; +}): string { + const hash = crypto + .createHash("sha256") + .update( + JSON.stringify({ + pluginId: params.pluginId, + channel: normalizeChannel(params.channel), + accountId: params.accountId, + conversationId: params.conversationId, + }), + ) + .digest("hex") + .slice(0, 24); + return `${PLUGIN_BINDING_SESSION_PREFIX}:${params.pluginId}:${hash}`; +} + +function isLegacyPluginBindingRecord(params: { + record: + | { + targetSessionKey: string; + metadata?: Record; + } + | null + | undefined; +}): boolean { + if (!params.record || isPluginOwnedBindingMetadata(params.record.metadata)) { + return false; + } + const targetSessionKey = params.record.targetSessionKey.trim(); + return ( + targetSessionKey.startsWith(`${PLUGIN_BINDING_SESSION_PREFIX}:`) || + LEGACY_CODEX_PLUGIN_SESSION_PREFIXES.some((prefix) => targetSessionKey.startsWith(prefix)) + ); +} + +function buildDiscordButtonRow( + approvalId: string, + labels?: { once?: string; always?: string; deny?: string }, +): TopLevelComponents[] { + return [ + new Row([ + new PluginBindingApprovalButton({ + approvalId, + decision: "allow-once", + label: labels?.once ?? "Allow once", + style: ButtonStyle.Success, + }), + new PluginBindingApprovalButton({ + approvalId, + decision: "allow-always", + label: labels?.always ?? "Always allow", + style: ButtonStyle.Primary, + }), + new PluginBindingApprovalButton({ + approvalId, + decision: "deny", + label: labels?.deny ?? "Deny", + style: ButtonStyle.Danger, + }), + ]), + ]; +} + +function buildTelegramButtons(approvalId: string) { + return [ + [ + { + text: "Allow once", + callback_data: buildPluginBindingApprovalCustomId(approvalId, "allow-once"), + style: "success" as const, + }, + { + text: "Always allow", + callback_data: buildPluginBindingApprovalCustomId(approvalId, "allow-always"), + style: "primary" as const, + }, + { + text: "Deny", + callback_data: buildPluginBindingApprovalCustomId(approvalId, "deny"), + style: "danger" as const, + }, + ], + ]; +} + +function createApprovalRequestId(): string { + // Keep approval ids compact so Telegram callback_data stays under its 64-byte limit. + return crypto.randomBytes(9).toString("base64url"); +} + +function loadApprovalsFromDisk(): PluginBindingApprovalsFile { + const filePath = resolveApprovalsPath(); + try { + if (!fs.existsSync(filePath)) { + return { version: 1, approvals: [] }; + } + const raw = fs.readFileSync(filePath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if (!Array.isArray(parsed.approvals)) { + return { version: 1, approvals: [] }; + } + return { + version: 1, + approvals: parsed.approvals + .filter((entry): entry is PluginBindingApprovalEntry => + Boolean(entry && typeof entry === "object"), + ) + .map((entry) => ({ + pluginRoot: typeof entry.pluginRoot === "string" ? entry.pluginRoot : "", + pluginId: typeof entry.pluginId === "string" ? entry.pluginId : "", + pluginName: typeof entry.pluginName === "string" ? entry.pluginName : undefined, + channel: typeof entry.channel === "string" ? normalizeChannel(entry.channel) : "", + accountId: + typeof entry.accountId === "string" ? entry.accountId.trim() || "default" : "default", + approvedAt: + typeof entry.approvedAt === "number" && Number.isFinite(entry.approvedAt) + ? Math.floor(entry.approvedAt) + : Date.now(), + })) + .filter((entry) => entry.pluginRoot && entry.pluginId && entry.channel), + }; + } catch (error) { + log.warn(`plugin binding approvals load failed: ${String(error)}`); + return { version: 1, approvals: [] }; + } +} + +async function saveApprovals(file: PluginBindingApprovalsFile): Promise { + const filePath = resolveApprovalsPath(); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + approvalsCache = file; + approvalsLoaded = true; + await writeJsonAtomic(filePath, file, { + mode: 0o600, + trailingNewline: true, + }); +} + +function getApprovals(): PluginBindingApprovalsFile { + if (!approvalsLoaded || !approvalsCache) { + approvalsCache = loadApprovalsFromDisk(); + approvalsLoaded = true; + } + return approvalsCache; +} + +function hasPersistentApproval(params: { + pluginRoot: string; + channel: string; + accountId: string; +}): boolean { + const key = buildApprovalScopeKey(params); + return getApprovals().approvals.some( + (entry) => + buildApprovalScopeKey({ + pluginRoot: entry.pluginRoot, + channel: entry.channel, + accountId: entry.accountId, + }) === key, + ); +} + +async function addPersistentApproval(entry: PluginBindingApprovalEntry): Promise { + const file = getApprovals(); + const key = buildApprovalScopeKey(entry); + const approvals = file.approvals.filter( + (existing) => + buildApprovalScopeKey({ + pluginRoot: existing.pluginRoot, + channel: existing.channel, + accountId: existing.accountId, + }) !== key, + ); + approvals.push(entry); + await saveApprovals({ + version: 1, + approvals, + }); +} + +function buildBindingMetadata(params: { + pluginId: string; + pluginName?: string; + pluginRoot: string; + summary?: string; + detachHint?: string; +}): PluginBindingMetadata { + return { + pluginBindingOwner: PLUGIN_BINDING_OWNER, + pluginId: params.pluginId, + pluginName: params.pluginName, + pluginRoot: params.pluginRoot, + summary: params.summary?.trim() || undefined, + detachHint: params.detachHint?.trim() || undefined, + }; +} + +export function isPluginOwnedBindingMetadata(metadata: unknown): metadata is PluginBindingMetadata { + if (!metadata || typeof metadata !== "object") { + return false; + } + const record = metadata as Record; + return ( + record.pluginBindingOwner === PLUGIN_BINDING_OWNER && + typeof record.pluginId === "string" && + typeof record.pluginRoot === "string" + ); +} + +export function isPluginOwnedSessionBindingRecord( + record: + | { + metadata?: Record; + } + | null + | undefined, +): boolean { + return isPluginOwnedBindingMetadata(record?.metadata); +} + +export function toPluginConversationBinding( + record: + | { + bindingId: string; + conversation: ConversationRef; + boundAt: number; + metadata?: Record; + } + | null + | undefined, +): PluginConversationBinding | null { + if (!record || !isPluginOwnedBindingMetadata(record.metadata)) { + return null; + } + const metadata = record.metadata; + return { + bindingId: record.bindingId, + pluginId: metadata.pluginId, + pluginName: metadata.pluginName, + pluginRoot: metadata.pluginRoot, + channel: record.conversation.channel, + accountId: record.conversation.accountId, + conversationId: record.conversation.conversationId, + parentConversationId: record.conversation.parentConversationId, + boundAt: record.boundAt, + summary: metadata.summary, + detachHint: metadata.detachHint, + }; +} + +async function bindConversationNow(params: { + identity: PluginBindingIdentity; + conversation: PluginBindingConversation; + summary?: string; + detachHint?: string; +}): Promise { + const ref = toConversationRef(params.conversation); + const targetSessionKey = buildPluginBindingSessionKey({ + pluginId: params.identity.pluginId, + channel: ref.channel, + accountId: ref.accountId, + conversationId: ref.conversationId, + }); + const record = await getSessionBindingService().bind({ + targetSessionKey, + targetKind: "session", + conversation: ref, + placement: "current", + metadata: buildBindingMetadata({ + pluginId: params.identity.pluginId, + pluginName: params.identity.pluginName, + pluginRoot: params.identity.pluginRoot, + summary: params.summary, + detachHint: params.detachHint, + }), + }); + const binding = toPluginConversationBinding(record); + if (!binding) { + throw new Error("plugin binding was created without plugin metadata"); + } + return { + ...binding, + parentConversationId: params.conversation.parentConversationId, + threadId: params.conversation.threadId, + }; +} + +function buildApprovalMessage(request: PendingPluginBindingRequest): string { + const lines = [ + `Plugin bind approval required`, + `Plugin: ${request.pluginName ?? request.pluginId}`, + `Channel: ${request.conversation.channel}`, + `Account: ${request.conversation.accountId}`, + ]; + if (request.summary?.trim()) { + lines.push(`Request: ${request.summary.trim()}`); + } else { + lines.push("Request: Bind this conversation so future plain messages route to the plugin."); + } + lines.push("Choose whether to allow this plugin to bind the current conversation."); + return lines.join("\n"); +} + +function resolvePluginBindingDisplayName(binding: { + pluginId: string; + pluginName?: string; +}): string { + return binding.pluginName?.trim() || binding.pluginId; +} + +function buildDetachHintSuffix(detachHint?: string): string { + const trimmed = detachHint?.trim(); + return trimmed ? ` To detach this conversation, use ${trimmed}.` : ""; +} + +export function buildPluginBindingUnavailableText(binding: PluginConversationBinding): string { + return `The bound plugin ${resolvePluginBindingDisplayName(binding)} is not currently loaded. Routing this message to OpenClaw instead.${buildDetachHintSuffix(binding.detachHint)}`; +} + +export function buildPluginBindingDeclinedText(binding: PluginConversationBinding): string { + return `The bound plugin ${resolvePluginBindingDisplayName(binding)} did not handle this message. This conversation is still bound to that plugin.${buildDetachHintSuffix(binding.detachHint)}`; +} + +export function buildPluginBindingErrorText(binding: PluginConversationBinding): string { + return `The bound plugin ${resolvePluginBindingDisplayName(binding)} hit an error handling this message. This conversation is still bound to that plugin.${buildDetachHintSuffix(binding.detachHint)}`; +} + +export function hasShownPluginBindingFallbackNotice(bindingId: string): boolean { + const normalized = bindingId.trim(); + if (!normalized) { + return false; + } + return getPluginBindingGlobalState().fallbackNoticeBindingIds.has(normalized); +} + +export function markPluginBindingFallbackNoticeShown(bindingId: string): void { + const normalized = bindingId.trim(); + if (!normalized) { + return; + } + getPluginBindingGlobalState().fallbackNoticeBindingIds.add(normalized); +} + +function buildPendingReply(request: PendingPluginBindingRequest): ReplyPayload { + return { + text: buildApprovalMessage(request), + channelData: { + telegram: { + buttons: buildTelegramButtons(request.id), + }, + discord: { + components: buildDiscordButtonRow(request.id), + }, + }, + }; +} + +function encodeCustomIdValue(value: string): string { + return encodeURIComponent(value); +} + +function decodeCustomIdValue(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +export function buildPluginBindingApprovalCustomId( + approvalId: string, + decision: PluginBindingApprovalDecision, +): string { + const decisionCode = decision === "allow-once" ? "o" : decision === "allow-always" ? "a" : "d"; + return `${PLUGIN_BINDING_CUSTOM_ID_PREFIX}:${encodeCustomIdValue(approvalId)}:${decisionCode}`; +} + +export function parsePluginBindingApprovalCustomId( + value: string, +): PluginBindingApprovalAction | null { + const trimmed = value.trim(); + if (!trimmed.startsWith(`${PLUGIN_BINDING_CUSTOM_ID_PREFIX}:`)) { + return null; + } + const body = trimmed.slice(`${PLUGIN_BINDING_CUSTOM_ID_PREFIX}:`.length); + const separator = body.lastIndexOf(":"); + if (separator <= 0 || separator === body.length - 1) { + return null; + } + const rawId = body.slice(0, separator).trim(); + const rawDecisionCode = body.slice(separator + 1).trim(); + if (!rawId) { + return null; + } + const rawDecision = + rawDecisionCode === "o" + ? "allow-once" + : rawDecisionCode === "a" + ? "allow-always" + : rawDecisionCode === "d" + ? "deny" + : null; + if (!rawDecision) { + return null; + } + return { + approvalId: decodeCustomIdValue(rawId), + decision: rawDecision, + }; +} + +export async function requestPluginConversationBinding(params: { + pluginId: string; + pluginName?: string; + pluginRoot: string; + conversation: PluginBindingConversation; + requestedBySenderId?: string; + binding: PluginConversationBindingRequestParams | undefined; +}): Promise { + const conversation = normalizeConversation(params.conversation); + const ref = toConversationRef(conversation); + const existing = getSessionBindingService().resolveByConversation(ref); + const existingPluginBinding = toPluginConversationBinding(existing); + const existingLegacyPluginBinding = isLegacyPluginBindingRecord({ + record: existing, + }); + if (existing && !existingPluginBinding) { + if (existingLegacyPluginBinding) { + log.info( + `plugin binding migrating legacy record plugin=${params.pluginId} root=${params.pluginRoot} channel=${ref.channel} account=${ref.accountId} conversation=${ref.conversationId}`, + ); + } else { + return { + status: "error", + message: + "This conversation is already bound by core routing and cannot be claimed by a plugin.", + }; + } + } + if (existingPluginBinding && existingPluginBinding.pluginRoot !== params.pluginRoot) { + return { + status: "error", + message: `This conversation is already bound by plugin "${existingPluginBinding.pluginName ?? existingPluginBinding.pluginId}".`, + }; + } + + if (existingPluginBinding && existingPluginBinding.pluginRoot === params.pluginRoot) { + const rebound = await bindConversationNow({ + identity: { + pluginId: params.pluginId, + pluginName: params.pluginName, + pluginRoot: params.pluginRoot, + }, + conversation, + summary: params.binding?.summary, + detachHint: params.binding?.detachHint, + }); + log.info( + `plugin binding auto-refresh plugin=${params.pluginId} root=${params.pluginRoot} channel=${ref.channel} account=${ref.accountId} conversation=${ref.conversationId}`, + ); + return { status: "bound", binding: rebound }; + } + + if ( + hasPersistentApproval({ + pluginRoot: params.pluginRoot, + channel: ref.channel, + accountId: ref.accountId, + }) + ) { + const bound = await bindConversationNow({ + identity: { + pluginId: params.pluginId, + pluginName: params.pluginName, + pluginRoot: params.pluginRoot, + }, + conversation, + summary: params.binding?.summary, + detachHint: params.binding?.detachHint, + }); + log.info( + `plugin binding auto-approved plugin=${params.pluginId} root=${params.pluginRoot} channel=${ref.channel} account=${ref.accountId} conversation=${ref.conversationId}`, + ); + return { status: "bound", binding: bound }; + } + + const request: PendingPluginBindingRequest = { + id: createApprovalRequestId(), + pluginId: params.pluginId, + pluginName: params.pluginName, + pluginRoot: params.pluginRoot, + conversation, + requestedAt: Date.now(), + requestedBySenderId: params.requestedBySenderId?.trim() || undefined, + summary: params.binding?.summary?.trim() || undefined, + detachHint: params.binding?.detachHint?.trim() || undefined, + }; + pendingRequests.set(request.id, request); + log.info( + `plugin binding requested plugin=${params.pluginId} root=${params.pluginRoot} channel=${ref.channel} account=${ref.accountId} conversation=${ref.conversationId}`, + ); + return { + status: "pending", + approvalId: request.id, + reply: buildPendingReply(request), + }; +} + +export async function getCurrentPluginConversationBinding(params: { + pluginRoot: string; + conversation: PluginBindingConversation; +}): Promise { + const record = getSessionBindingService().resolveByConversation( + toConversationRef(params.conversation), + ); + const binding = toPluginConversationBinding(record); + if (!binding || binding.pluginRoot !== params.pluginRoot) { + return null; + } + return { + ...binding, + parentConversationId: params.conversation.parentConversationId, + threadId: params.conversation.threadId, + }; +} + +export async function detachPluginConversationBinding(params: { + pluginRoot: string; + conversation: PluginBindingConversation; +}): Promise<{ removed: boolean }> { + const ref = toConversationRef(params.conversation); + const record = getSessionBindingService().resolveByConversation(ref); + const binding = toPluginConversationBinding(record); + if (!binding || binding.pluginRoot !== params.pluginRoot) { + return { removed: false }; + } + await getSessionBindingService().unbind({ + bindingId: binding.bindingId, + reason: "plugin-detach", + }); + log.info( + `plugin binding detached plugin=${binding.pluginId} root=${binding.pluginRoot} channel=${binding.channel} account=${binding.accountId} conversation=${binding.conversationId}`, + ); + return { removed: true }; +} + +export async function resolvePluginConversationBindingApproval(params: { + approvalId: string; + decision: PluginBindingApprovalDecision; + senderId?: string; +}): Promise { + const request = pendingRequests.get(params.approvalId); + if (!request) { + return { status: "expired" }; + } + if ( + request.requestedBySenderId && + params.senderId?.trim() && + request.requestedBySenderId !== params.senderId.trim() + ) { + return { status: "expired" }; + } + pendingRequests.delete(params.approvalId); + if (params.decision === "deny") { + log.info( + `plugin binding denied plugin=${request.pluginId} root=${request.pluginRoot} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`, + ); + return { status: "denied", request }; + } + if (params.decision === "allow-always") { + await addPersistentApproval({ + pluginRoot: request.pluginRoot, + pluginId: request.pluginId, + pluginName: request.pluginName, + channel: request.conversation.channel, + accountId: request.conversation.accountId, + approvedAt: Date.now(), + }); + } + const binding = await bindConversationNow({ + identity: { + pluginId: request.pluginId, + pluginName: request.pluginName, + pluginRoot: request.pluginRoot, + }, + conversation: request.conversation, + summary: request.summary, + detachHint: request.detachHint, + }); + log.info( + `plugin binding approved plugin=${request.pluginId} root=${request.pluginRoot} decision=${params.decision} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`, + ); + return { + status: "approved", + binding, + request, + decision: params.decision, + }; +} + +export function buildPluginBindingResolvedText(params: PluginBindingResolveResult): string { + if (params.status === "expired") { + return "That plugin bind approval expired. Retry the bind command."; + } + if (params.status === "denied") { + return `Denied plugin bind request for ${params.request.pluginName ?? params.request.pluginId}.`; + } + const summarySuffix = params.request.summary?.trim() ? ` ${params.request.summary.trim()}` : ""; + if (params.decision === "allow-always") { + return `Allowed ${params.request.pluginName ?? params.request.pluginId} to bind this conversation.${summarySuffix}`; + } + return `Allowed ${params.request.pluginName ?? params.request.pluginId} to bind this conversation once.${summarySuffix}`; +} + +export const __testing = { + reset() { + pendingRequests.clear(); + approvalsCache = null; + approvalsLoaded = false; + getPluginBindingGlobalState().fallbackNoticeBindingIds.clear(); + }, +}; diff --git a/src/plugins/hooks.test-helpers.ts b/src/plugins/hooks.test-helpers.ts index 8b7076239c2..7954257e714 100644 --- a/src/plugins/hooks.test-helpers.ts +++ b/src/plugins/hooks.test-helpers.ts @@ -5,6 +5,27 @@ export function createMockPluginRegistry( hooks: Array<{ hookName: string; handler: (...args: unknown[]) => unknown }>, ): PluginRegistry { return { + plugins: [ + { + id: "test-plugin", + name: "Test Plugin", + source: "test", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: hooks.length, + configSchema: false, + }, + ], hooks: hooks as never[], typedHooks: hooks.map((h) => ({ pluginId: "test-plugin", diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index 4d74267d4ca..cffafd6645d 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -19,6 +19,9 @@ import type { PluginHookBeforePromptBuildEvent, PluginHookBeforePromptBuildResult, PluginHookBeforeCompactionEvent, + PluginHookInboundClaimContext, + PluginHookInboundClaimEvent, + PluginHookInboundClaimResult, PluginHookLlmInputEvent, PluginHookLlmOutputEvent, PluginHookBeforeResetEvent, @@ -66,6 +69,9 @@ export type { PluginHookAgentEndEvent, PluginHookBeforeCompactionEvent, PluginHookBeforeResetEvent, + PluginHookInboundClaimContext, + PluginHookInboundClaimEvent, + PluginHookInboundClaimResult, PluginHookAfterCompactionEvent, PluginHookMessageContext, PluginHookMessageReceivedEvent, @@ -108,6 +114,25 @@ export type HookRunnerOptions = { catchErrors?: boolean; }; +export type PluginTargetedInboundClaimOutcome = + | { + status: "handled"; + result: PluginHookInboundClaimResult; + } + | { + status: "missing_plugin"; + } + | { + status: "no_handler"; + } + | { + status: "declined"; + } + | { + status: "error"; + error: string; + }; + /** * Get hooks for a specific hook name, sorted by priority (higher first). */ @@ -120,6 +145,14 @@ function getHooksForName( .toSorted((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); } +function getHooksForNameAndPlugin( + registry: PluginRegistry, + hookName: K, + pluginId: string, +): PluginHookRegistration[] { + return getHooksForName(registry, hookName).filter((hook) => hook.pluginId === pluginId); +} + /** * Create a hook runner for a specific registry. */ @@ -196,6 +229,12 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp throw new Error(msg, { cause: params.error }); }; + const sanitizeHookError = (error: unknown): string => { + const raw = error instanceof Error ? error.message : String(error); + const firstLine = raw.split("\n")[0]?.trim(); + return firstLine || "unknown error"; + }; + /** * Run a hook that doesn't return a value (fire-and-forget style). * All handlers are executed in parallel for performance. @@ -263,6 +302,123 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp return result; } + /** + * Run a sequential claim hook where the first `{ handled: true }` result wins. + */ + async function runClaimingHook( + hookName: K, + event: Parameters["handler"]>>[0], + ctx: Parameters["handler"]>>[1], + ): Promise { + const hooks = getHooksForName(registry, hookName); + if (hooks.length === 0) { + return undefined; + } + + 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; + } + + async function runClaimingHookForPlugin< + K extends PluginHookName, + TResult extends { handled: boolean }, + >( + hookName: K, + pluginId: string, + event: Parameters["handler"]>>[0], + ctx: Parameters["handler"]>>[1], + ): Promise { + const hooks = getHooksForNameAndPlugin(registry, hookName, pluginId); + if (hooks.length === 0) { + return undefined; + } + + logger?.debug?.( + `[hooks] running ${hookName} for ${pluginId} (${hooks.length} handlers, targeted)`, + ); + + 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; + } + + async function runClaimingHookForPluginOutcome< + K extends PluginHookName, + TResult extends { handled: boolean }, + >( + hookName: K, + pluginId: string, + event: Parameters["handler"]>>[0], + ctx: Parameters["handler"]>>[1], + ): Promise< + | { status: "handled"; result: TResult } + | { status: "missing_plugin" } + | { status: "no_handler" } + | { status: "declined" } + | { status: "error"; error: string } + > { + const pluginLoaded = registry.plugins.some( + (plugin) => plugin.id === pluginId && plugin.status === "loaded", + ); + if (!pluginLoaded) { + return { status: "missing_plugin" }; + } + + const hooks = getHooksForNameAndPlugin(registry, hookName, pluginId); + if (hooks.length === 0) { + return { status: "no_handler" }; + } + + logger?.debug?.( + `[hooks] running ${hookName} for ${pluginId} (${hooks.length} handlers, targeted outcome)`, + ); + + let firstError: string | null = null; + for (const hook of hooks) { + try { + const handlerResult = await ( + hook.handler as (event: unknown, ctx: unknown) => Promise + )(event, ctx); + if (handlerResult?.handled) { + return { status: "handled", result: handlerResult }; + } + } catch (err) { + firstError ??= sanitizeHookError(err); + handleHookError({ hookName, pluginId: hook.pluginId, error: err }); + } + } + + if (firstError) { + return { status: "error", error: firstError }; + } + return { status: "declined" }; + } + // ========================================================================= // Agent Hooks // ========================================================================= @@ -384,6 +540,47 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp // Message Hooks // ========================================================================= + /** + * Run inbound_claim hook. + * Allows plugins to claim an inbound event before commands/agent dispatch. + */ + async function runInboundClaim( + event: PluginHookInboundClaimEvent, + ctx: PluginHookInboundClaimContext, + ): Promise { + return runClaimingHook<"inbound_claim", PluginHookInboundClaimResult>( + "inbound_claim", + event, + ctx, + ); + } + + async function runInboundClaimForPlugin( + pluginId: string, + event: PluginHookInboundClaimEvent, + ctx: PluginHookInboundClaimContext, + ): Promise { + return runClaimingHookForPlugin<"inbound_claim", PluginHookInboundClaimResult>( + "inbound_claim", + pluginId, + event, + ctx, + ); + } + + async function runInboundClaimForPluginOutcome( + pluginId: string, + event: PluginHookInboundClaimEvent, + ctx: PluginHookInboundClaimContext, + ): Promise { + return runClaimingHookForPluginOutcome<"inbound_claim", PluginHookInboundClaimResult>( + "inbound_claim", + pluginId, + event, + ctx, + ); + } + /** * Run message_received hook. * Runs in parallel (fire-and-forget). @@ -734,6 +931,9 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp runAfterCompaction, runBeforeReset, // Message hooks + runInboundClaim, + runInboundClaimForPlugin, + runInboundClaimForPluginOutcome, runMessageReceived, runMessageSending, runMessageSent, diff --git a/src/plugins/interactive.test.ts b/src/plugins/interactive.test.ts new file mode 100644 index 00000000000..f794cde4037 --- /dev/null +++ b/src/plugins/interactive.test.ts @@ -0,0 +1,201 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearPluginInteractiveHandlers, + dispatchPluginInteractiveHandler, + registerPluginInteractiveHandler, +} from "./interactive.js"; + +describe("plugin interactive handlers", () => { + beforeEach(() => { + clearPluginInteractiveHandlers(); + }); + + it("routes Telegram callbacks by namespace and dedupes callback ids", async () => { + const handler = vi.fn(async () => ({ handled: true })); + expect( + registerPluginInteractiveHandler("codex-plugin", { + channel: "telegram", + namespace: "codex", + handler, + }), + ).toEqual({ ok: true }); + + const baseParams = { + channel: "telegram" as const, + data: "codex:resume:thread-1", + callbackId: "cb-1", + ctx: { + accountId: "default", + callbackId: "cb-1", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + senderId: "user-1", + senderUsername: "ada", + threadId: 77, + isGroup: true, + isForum: true, + auth: { isAuthorizedSender: true }, + callbackMessage: { + messageId: 55, + chatId: "-10099", + messageText: "Pick a thread", + }, + }, + respond: { + reply: vi.fn(async () => {}), + editMessage: vi.fn(async () => {}), + editButtons: vi.fn(async () => {}), + clearButtons: vi.fn(async () => {}), + deleteMessage: vi.fn(async () => {}), + }, + }; + + const first = await dispatchPluginInteractiveHandler(baseParams); + const duplicate = await dispatchPluginInteractiveHandler(baseParams); + + expect(first).toEqual({ matched: true, handled: true, duplicate: false }); + expect(duplicate).toEqual({ matched: true, handled: true, duplicate: true }); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + conversationId: "-10099:topic:77", + callback: expect.objectContaining({ + namespace: "codex", + payload: "resume:thread-1", + chatId: "-10099", + messageId: 55, + }), + }), + ); + }); + + it("rejects duplicate namespace registrations", () => { + const first = registerPluginInteractiveHandler("plugin-a", { + channel: "telegram", + namespace: "codex", + handler: async () => ({ handled: true }), + }); + const second = registerPluginInteractiveHandler("plugin-b", { + channel: "telegram", + namespace: "codex", + handler: async () => ({ handled: true }), + }); + + expect(first).toEqual({ ok: true }); + expect(second).toEqual({ + ok: false, + error: 'Interactive handler namespace "codex" already registered by plugin "plugin-a"', + }); + }); + + it("routes Discord interactions by namespace and dedupes interaction ids", async () => { + const handler = vi.fn(async () => ({ handled: true })); + expect( + registerPluginInteractiveHandler("codex-plugin", { + channel: "discord", + namespace: "codex", + handler, + }), + ).toEqual({ ok: true }); + + const baseParams = { + channel: "discord" as const, + data: "codex:approve:thread-1", + interactionId: "ix-1", + ctx: { + accountId: "default", + interactionId: "ix-1", + conversationId: "channel-1", + parentConversationId: "parent-1", + guildId: "guild-1", + senderId: "user-1", + senderUsername: "ada", + auth: { isAuthorizedSender: true }, + interaction: { + kind: "button" as const, + messageId: "message-1", + values: ["allow"], + }, + }, + respond: { + acknowledge: vi.fn(async () => {}), + reply: vi.fn(async () => {}), + followUp: vi.fn(async () => {}), + editMessage: vi.fn(async () => {}), + clearComponents: vi.fn(async () => {}), + }, + }; + + const first = await dispatchPluginInteractiveHandler(baseParams); + const duplicate = await dispatchPluginInteractiveHandler(baseParams); + + expect(first).toEqual({ matched: true, handled: true, duplicate: false }); + expect(duplicate).toEqual({ matched: true, handled: true, duplicate: true }); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "discord", + conversationId: "channel-1", + interaction: expect.objectContaining({ + namespace: "codex", + payload: "approve:thread-1", + messageId: "message-1", + values: ["allow"], + }), + }), + ); + }); + + it("does not consume dedupe keys when a handler throws", async () => { + const handler = vi + .fn(async () => ({ handled: true })) + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce({ handled: true }); + expect( + registerPluginInteractiveHandler("codex-plugin", { + channel: "telegram", + namespace: "codex", + handler, + }), + ).toEqual({ ok: true }); + + const baseParams = { + channel: "telegram" as const, + data: "codex:resume:thread-1", + callbackId: "cb-throw", + ctx: { + accountId: "default", + callbackId: "cb-throw", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + senderId: "user-1", + senderUsername: "ada", + threadId: 77, + isGroup: true, + isForum: true, + auth: { isAuthorizedSender: true }, + callbackMessage: { + messageId: 55, + chatId: "-10099", + messageText: "Pick a thread", + }, + }, + respond: { + reply: vi.fn(async () => {}), + editMessage: vi.fn(async () => {}), + editButtons: vi.fn(async () => {}), + clearButtons: vi.fn(async () => {}), + deleteMessage: vi.fn(async () => {}), + }, + }; + + await expect(dispatchPluginInteractiveHandler(baseParams)).rejects.toThrow("boom"); + await expect(dispatchPluginInteractiveHandler(baseParams)).resolves.toEqual({ + matched: true, + handled: true, + duplicate: false, + }); + expect(handler).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/plugins/interactive.ts b/src/plugins/interactive.ts new file mode 100644 index 00000000000..66d79fd71ec --- /dev/null +++ b/src/plugins/interactive.ts @@ -0,0 +1,366 @@ +import { createDedupeCache } from "../infra/dedupe.js"; +import { + detachPluginConversationBinding, + getCurrentPluginConversationBinding, + requestPluginConversationBinding, +} from "./conversation-binding.js"; +import type { + PluginInteractiveDiscordHandlerContext, + PluginInteractiveButtons, + PluginInteractiveDiscordHandlerRegistration, + PluginInteractiveHandlerRegistration, + PluginInteractiveTelegramHandlerRegistration, + PluginInteractiveTelegramHandlerContext, +} from "./types.js"; + +type RegisteredInteractiveHandler = PluginInteractiveHandlerRegistration & { + pluginId: string; + pluginName?: string; + pluginRoot?: string; +}; + +type InteractiveRegistrationResult = { + ok: boolean; + error?: string; +}; + +type InteractiveDispatchResult = + | { matched: false; handled: false; duplicate: false } + | { matched: true; handled: boolean; duplicate: boolean }; + +type TelegramInteractiveDispatchContext = Omit< + PluginInteractiveTelegramHandlerContext, + | "callback" + | "respond" + | "channel" + | "requestConversationBinding" + | "detachConversationBinding" + | "getCurrentConversationBinding" +> & { + callbackMessage: { + messageId: number; + chatId: string; + messageText?: string; + }; +}; + +type DiscordInteractiveDispatchContext = Omit< + PluginInteractiveDiscordHandlerContext, + | "interaction" + | "respond" + | "channel" + | "requestConversationBinding" + | "detachConversationBinding" + | "getCurrentConversationBinding" +> & { + interaction: Omit< + PluginInteractiveDiscordHandlerContext["interaction"], + "data" | "namespace" | "payload" + >; +}; + +const interactiveHandlers = new Map(); +const callbackDedupe = createDedupeCache({ + ttlMs: 5 * 60_000, + maxSize: 4096, +}); + +function toRegistryKey(channel: string, namespace: string): string { + return `${channel.trim().toLowerCase()}:${namespace.trim()}`; +} + +function normalizeNamespace(namespace: string): string { + return namespace.trim(); +} + +function validateNamespace(namespace: string): string | null { + if (!namespace.trim()) { + return "Interactive handler namespace cannot be empty"; + } + if (!/^[A-Za-z0-9._-]+$/.test(namespace.trim())) { + return "Interactive handler namespace must contain only letters, numbers, dots, underscores, and hyphens"; + } + return null; +} + +function resolveNamespaceMatch( + channel: string, + data: string, +): { registration: RegisteredInteractiveHandler; namespace: string; payload: string } | null { + const trimmedData = data.trim(); + if (!trimmedData) { + return null; + } + + const separatorIndex = trimmedData.indexOf(":"); + const namespace = + separatorIndex >= 0 ? trimmedData.slice(0, separatorIndex) : normalizeNamespace(trimmedData); + const registration = interactiveHandlers.get(toRegistryKey(channel, namespace)); + if (!registration) { + return null; + } + + return { + registration, + namespace, + payload: separatorIndex >= 0 ? trimmedData.slice(separatorIndex + 1) : "", + }; +} + +export function registerPluginInteractiveHandler( + pluginId: string, + registration: PluginInteractiveHandlerRegistration, + opts?: { pluginName?: string; pluginRoot?: string }, +): InteractiveRegistrationResult { + const namespace = normalizeNamespace(registration.namespace); + const validationError = validateNamespace(namespace); + if (validationError) { + return { ok: false, error: validationError }; + } + const key = toRegistryKey(registration.channel, namespace); + const existing = interactiveHandlers.get(key); + if (existing) { + return { + ok: false, + error: `Interactive handler namespace "${namespace}" already registered by plugin "${existing.pluginId}"`, + }; + } + if (registration.channel === "telegram") { + interactiveHandlers.set(key, { + ...registration, + namespace, + channel: "telegram", + pluginId, + pluginName: opts?.pluginName, + pluginRoot: opts?.pluginRoot, + }); + } else { + interactiveHandlers.set(key, { + ...registration, + namespace, + channel: "discord", + pluginId, + pluginName: opts?.pluginName, + pluginRoot: opts?.pluginRoot, + }); + } + return { ok: true }; +} + +export function clearPluginInteractiveHandlers(): void { + interactiveHandlers.clear(); + callbackDedupe.clear(); +} + +export function clearPluginInteractiveHandlersForPlugin(pluginId: string): void { + for (const [key, value] of interactiveHandlers.entries()) { + if (value.pluginId === pluginId) { + interactiveHandlers.delete(key); + } + } +} + +export async function dispatchPluginInteractiveHandler(params: { + channel: "telegram"; + data: string; + callbackId: string; + ctx: TelegramInteractiveDispatchContext; + respond: { + reply: (params: { text: string; buttons?: PluginInteractiveButtons }) => Promise; + editMessage: (params: { text: string; buttons?: PluginInteractiveButtons }) => Promise; + editButtons: (params: { buttons: PluginInteractiveButtons }) => Promise; + clearButtons: () => Promise; + deleteMessage: () => Promise; + }; +}): Promise; +export async function dispatchPluginInteractiveHandler(params: { + channel: "discord"; + data: string; + interactionId: string; + ctx: DiscordInteractiveDispatchContext; + respond: PluginInteractiveDiscordHandlerContext["respond"]; +}): Promise; +export async function dispatchPluginInteractiveHandler(params: { + channel: "telegram" | "discord"; + data: string; + callbackId?: string; + interactionId?: string; + ctx: TelegramInteractiveDispatchContext | DiscordInteractiveDispatchContext; + respond: + | { + reply: (params: { text: string; buttons?: PluginInteractiveButtons }) => Promise; + editMessage: (params: { + text: string; + buttons?: PluginInteractiveButtons; + }) => Promise; + editButtons: (params: { buttons: PluginInteractiveButtons }) => Promise; + clearButtons: () => Promise; + deleteMessage: () => Promise; + } + | PluginInteractiveDiscordHandlerContext["respond"]; +}): Promise { + const match = resolveNamespaceMatch(params.channel, params.data); + if (!match) { + return { matched: false, handled: false, duplicate: false }; + } + + const dedupeKey = + params.channel === "telegram" ? params.callbackId?.trim() : params.interactionId?.trim(); + if (dedupeKey && callbackDedupe.peek(dedupeKey)) { + return { matched: true, handled: true, duplicate: true }; + } + + let result: + | ReturnType + | ReturnType; + if (params.channel === "telegram") { + const pluginRoot = match.registration.pluginRoot; + const { callbackMessage, ...handlerContext } = params.ctx as TelegramInteractiveDispatchContext; + result = ( + match.registration as RegisteredInteractiveHandler & + PluginInteractiveTelegramHandlerRegistration + ).handler({ + ...handlerContext, + channel: "telegram", + callback: { + data: params.data, + namespace: match.namespace, + payload: match.payload, + messageId: callbackMessage.messageId, + chatId: callbackMessage.chatId, + messageText: callbackMessage.messageText, + }, + respond: params.respond as PluginInteractiveTelegramHandlerContext["respond"], + requestConversationBinding: async (bindingParams) => { + if (!pluginRoot) { + return { + status: "error", + message: "This interaction cannot bind the current conversation.", + }; + } + return requestPluginConversationBinding({ + pluginId: match.registration.pluginId, + pluginName: match.registration.pluginName, + pluginRoot, + requestedBySenderId: handlerContext.senderId, + conversation: { + channel: "telegram", + accountId: handlerContext.accountId, + conversationId: handlerContext.conversationId, + parentConversationId: handlerContext.parentConversationId, + threadId: handlerContext.threadId, + }, + binding: bindingParams, + }); + }, + detachConversationBinding: async () => { + if (!pluginRoot) { + return { removed: false }; + } + return detachPluginConversationBinding({ + pluginRoot, + conversation: { + channel: "telegram", + accountId: handlerContext.accountId, + conversationId: handlerContext.conversationId, + parentConversationId: handlerContext.parentConversationId, + threadId: handlerContext.threadId, + }, + }); + }, + getCurrentConversationBinding: async () => { + if (!pluginRoot) { + return null; + } + return getCurrentPluginConversationBinding({ + pluginRoot, + conversation: { + channel: "telegram", + accountId: handlerContext.accountId, + conversationId: handlerContext.conversationId, + parentConversationId: handlerContext.parentConversationId, + threadId: handlerContext.threadId, + }, + }); + }, + }); + } else { + const pluginRoot = match.registration.pluginRoot; + result = ( + match.registration as RegisteredInteractiveHandler & + PluginInteractiveDiscordHandlerRegistration + ).handler({ + ...(params.ctx as DiscordInteractiveDispatchContext), + channel: "discord", + interaction: { + ...(params.ctx as DiscordInteractiveDispatchContext).interaction, + data: params.data, + namespace: match.namespace, + payload: match.payload, + }, + respond: params.respond as PluginInteractiveDiscordHandlerContext["respond"], + requestConversationBinding: async (bindingParams) => { + if (!pluginRoot) { + return { + status: "error", + message: "This interaction cannot bind the current conversation.", + }; + } + const handlerContext = params.ctx as DiscordInteractiveDispatchContext; + return requestPluginConversationBinding({ + pluginId: match.registration.pluginId, + pluginName: match.registration.pluginName, + pluginRoot, + requestedBySenderId: handlerContext.senderId, + conversation: { + channel: "discord", + accountId: handlerContext.accountId, + conversationId: handlerContext.conversationId, + parentConversationId: handlerContext.parentConversationId, + }, + binding: bindingParams, + }); + }, + detachConversationBinding: async () => { + if (!pluginRoot) { + return { removed: false }; + } + const handlerContext = params.ctx as DiscordInteractiveDispatchContext; + return detachPluginConversationBinding({ + pluginRoot, + conversation: { + channel: "discord", + accountId: handlerContext.accountId, + conversationId: handlerContext.conversationId, + parentConversationId: handlerContext.parentConversationId, + }, + }); + }, + getCurrentConversationBinding: async () => { + if (!pluginRoot) { + return null; + } + const handlerContext = params.ctx as DiscordInteractiveDispatchContext; + return getCurrentPluginConversationBinding({ + pluginRoot, + conversation: { + channel: "discord", + accountId: handlerContext.accountId, + conversationId: handlerContext.conversationId, + parentConversationId: handlerContext.parentConversationId, + }, + }); + }, + }); + } + const resolved = await result; + if (dedupeKey) { + callbackDedupe.check(dedupeKey); + } + + return { + matched: true, + handled: resolved?.handled ?? true, + duplicate: false, + }; +} diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 20d5772d3f7..1549835d60a 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -19,6 +19,7 @@ import { } from "./config-state.js"; import { discoverOpenClawPlugins } from "./discovery.js"; import { initializeGlobalHookRunner } from "./hook-runner-global.js"; +import { clearPluginInteractiveHandlers } from "./interactive.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; import { isPathInside, safeStatSync } from "./path-safety.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; @@ -317,6 +318,7 @@ function createPluginRecord(params: { description?: string; version?: string; source: string; + rootDir?: string; origin: PluginRecord["origin"]; workspaceDir?: string; enabled: boolean; @@ -328,6 +330,7 @@ function createPluginRecord(params: { description: params.description, version: params.version, source: params.source, + rootDir: params.rootDir, origin: params.origin, workspaceDir: params.workspaceDir, enabled: params.enabled, @@ -653,6 +656,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi // Clear previously registered plugin commands before reloading clearPluginCommands(); + clearPluginInteractiveHandlers(); // Lazily initialize the runtime so startup paths that discover/skip plugins do // not eagerly load every channel runtime dependency. @@ -782,6 +786,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi description: manifestRecord.description, version: manifestRecord.version, source: candidate.source, + rootDir: candidate.rootDir, origin: candidate.origin, workspaceDir: candidate.workspaceDir, enabled: false, @@ -806,6 +811,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi description: manifestRecord.description, version: manifestRecord.version, source: candidate.source, + rootDir: candidate.rootDir, origin: candidate.origin, workspaceDir: candidate.workspaceDir, enabled: enableState.enabled, diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index fe978d6a346..8d1e5f92eb0 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -13,6 +13,7 @@ import { resolveUserPath } from "../utils.js"; import { registerPluginCommand } from "./commands.js"; import { normalizePluginHttpPath } from "./http-path.js"; import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; +import { registerPluginInteractiveHandler } from "./interactive.js"; import { normalizeRegisteredProvider } from "./provider-validation.js"; import type { PluginRuntime } from "./runtime/types.js"; import { defaultSlotIdForKey } from "./slots.js"; @@ -47,17 +48,21 @@ import type { export type PluginToolRegistration = { pluginId: string; + pluginName?: string; factory: OpenClawPluginToolFactory; names: string[]; optional: boolean; source: string; + rootDir?: string; }; export type PluginCliRegistration = { pluginId: string; + pluginName?: string; register: OpenClawPluginCliRegistrar; commands: string[]; source: string; + rootDir?: string; }; export type PluginHttpRouteRegistration = { @@ -71,15 +76,19 @@ export type PluginHttpRouteRegistration = { export type PluginChannelRegistration = { pluginId: string; + pluginName?: string; plugin: ChannelPlugin; dock?: ChannelDock; source: string; + rootDir?: string; }; export type PluginProviderRegistration = { pluginId: string; + pluginName?: string; provider: ProviderPlugin; source: string; + rootDir?: string; }; export type PluginHookRegistration = { @@ -87,18 +96,23 @@ export type PluginHookRegistration = { entry: HookEntry; events: string[]; source: string; + rootDir?: string; }; export type PluginServiceRegistration = { pluginId: string; + pluginName?: string; service: OpenClawPluginService; source: string; + rootDir?: string; }; export type PluginCommandRegistration = { pluginId: string; + pluginName?: string; command: OpenClawPluginCommandDefinition; source: string; + rootDir?: string; }; export type PluginRecord = { @@ -108,6 +122,7 @@ export type PluginRecord = { description?: string; kind?: PluginKind; source: string; + rootDir?: string; origin: PluginOrigin; workspaceDir?: string; enabled: boolean; @@ -212,10 +227,12 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { } registry.tools.push({ pluginId: record.id, + pluginName: record.name, factory, names: normalized, optional, source: record.source, + rootDir: record.rootDir, }); }; @@ -443,9 +460,11 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { record.channelIds.push(id); registry.channels.push({ pluginId: record.id, + pluginName: record.name, plugin, dock: normalized.dock, source: record.source, + rootDir: record.rootDir, }); }; @@ -473,8 +492,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { record.providerIds.push(id); registry.providers.push({ pluginId: record.id, + pluginName: record.name, provider: normalizedProvider, source: record.source, + rootDir: record.rootDir, }); }; @@ -509,9 +530,11 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { record.cliCommands.push(...commands); registry.cliRegistrars.push({ pluginId: record.id, + pluginName: record.name, register: registrar, commands, source: record.source, + rootDir: record.rootDir, }); }; @@ -533,8 +556,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { record.services.push(id); registry.services.push({ pluginId: record.id, + pluginName: record.name, service, source: record.source, + rootDir: record.rootDir, }); }; @@ -551,7 +576,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { } // Register with the plugin command system (validates name and checks for duplicates) - const result = registerPluginCommand(record.id, command); + const result = registerPluginCommand(record.id, command, { + pluginName: record.name, + pluginRoot: record.rootDir, + }); if (!result.ok) { pushDiagnostic({ level: "error", @@ -565,8 +593,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { record.commands.push(name); registry.commands.push({ pluginId: record.id, + pluginName: record.name, command, source: record.source, + rootDir: record.rootDir, }); }; @@ -640,6 +670,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { version: record.version, description: record.description, source: record.source, + rootDir: record.rootDir, config: params.config, pluginConfig: params.pluginConfig, runtime: registryParams.runtime, @@ -653,6 +684,20 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler), registerCli: (registrar, opts) => registerCli(record, registrar, opts), registerService: (service) => registerService(record, service), + registerInteractiveHandler: (registration) => { + const result = registerPluginInteractiveHandler(record.id, registration, { + pluginName: record.name, + pluginRoot: record.rootDir, + }); + if (!result.ok) { + pushDiagnostic({ + level: "warn", + pluginId: record.id, + source: record.source, + message: result.error ?? "interactive handler registration failed", + }); + } + }, registerCommand: (command) => registerCommand(record, command), registerContextEngine: (id, factory) => { if (id === defaultSlotIdForKey("contextEngine")) { diff --git a/src/plugins/runtime/runtime-channel.ts b/src/plugins/runtime/runtime-channel.ts index 53a8f0ca936..94ea9a0b8cb 100644 --- a/src/plugins/runtime/runtime-channel.ts +++ b/src/plugins/runtime/runtime-channel.ts @@ -7,7 +7,18 @@ import { monitorDiscordProvider } from "../../../extensions/discord/src/monitor. import { probeDiscord } from "../../../extensions/discord/src/probe.js"; import { resolveDiscordChannelAllowlist } from "../../../extensions/discord/src/resolve-channels.js"; import { resolveDiscordUserAllowlist } from "../../../extensions/discord/src/resolve-users.js"; -import { sendMessageDiscord, sendPollDiscord } from "../../../extensions/discord/src/send.js"; +import { + createThreadDiscord, + deleteMessageDiscord, + editChannelDiscord, + editMessageDiscord, + pinMessageDiscord, + sendDiscordComponentMessage, + sendMessageDiscord, + sendPollDiscord, + sendTypingDiscord, + unpinMessageDiscord, +} from "../../../extensions/discord/src/send.js"; import { monitorIMessageProvider } from "../../../extensions/imessage/src/monitor.js"; import { probeIMessage } from "../../../extensions/imessage/src/probe.js"; import { sendMessageIMessage } from "../../../extensions/imessage/src/send.js"; @@ -29,7 +40,17 @@ import { } from "../../../extensions/telegram/src/audit.js"; import { monitorTelegramProvider } from "../../../extensions/telegram/src/monitor.js"; import { probeTelegram } from "../../../extensions/telegram/src/probe.js"; -import { sendMessageTelegram, sendPollTelegram } from "../../../extensions/telegram/src/send.js"; +import { + deleteMessageTelegram, + editMessageReplyMarkupTelegram, + editMessageTelegram, + pinMessageTelegram, + renameForumTopicTelegram, + sendMessageTelegram, + sendPollTelegram, + sendTypingTelegram, + unpinMessageTelegram, +} from "../../../extensions/telegram/src/send.js"; import { resolveTelegramToken } from "../../../extensions/telegram/src/token.js"; import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js"; import { handleSlackAction } from "../../agents/tools/slack-actions.js"; @@ -113,6 +134,8 @@ import { upsertChannelPairingRequest, } from "../../pairing/pairing-store.js"; import { buildAgentSessionKey, resolveAgentRoute } from "../../routing/resolve-route.js"; +import { createDiscordTypingLease } from "./runtime-discord-typing.js"; +import { createTelegramTypingLease } from "./runtime-telegram-typing.js"; import { createRuntimeWhatsApp } from "./runtime-whatsapp.js"; import type { PluginRuntime } from "./types.js"; @@ -207,9 +230,33 @@ export function createRuntimeChannel(): PluginRuntime["channel"] { probeDiscord, resolveChannelAllowlist: resolveDiscordChannelAllowlist, resolveUserAllowlist: resolveDiscordUserAllowlist, + sendComponentMessage: sendDiscordComponentMessage, sendMessageDiscord, sendPollDiscord, monitorDiscordProvider, + typing: { + pulse: sendTypingDiscord, + start: async ({ channelId, accountId, cfg, intervalMs }) => + await createDiscordTypingLease({ + channelId, + accountId, + cfg, + intervalMs, + pulse: async ({ channelId, accountId, cfg }) => + void (await sendTypingDiscord(channelId, { + accountId, + cfg, + })), + }), + }, + conversationActions: { + editMessage: editMessageDiscord, + deleteMessage: deleteMessageDiscord, + pinMessage: pinMessageDiscord, + unpinMessage: unpinMessageDiscord, + createThread: createThreadDiscord, + editChannel: editChannelDiscord, + }, }, slack: { listDirectoryGroupsLive: listSlackDirectoryGroupsLive, @@ -230,6 +277,33 @@ export function createRuntimeChannel(): PluginRuntime["channel"] { sendPollTelegram, monitorTelegramProvider, messageActions: telegramMessageActions, + typing: { + pulse: sendTypingTelegram, + start: async ({ to, accountId, cfg, intervalMs, messageThreadId }) => + await createTelegramTypingLease({ + to, + accountId, + cfg, + intervalMs, + messageThreadId, + pulse: async ({ to, accountId, cfg, messageThreadId }) => + await sendTypingTelegram(to, { + accountId, + cfg, + messageThreadId, + }), + }), + }, + conversationActions: { + editMessage: editMessageTelegram, + editReplyMarkup: editMessageReplyMarkupTelegram, + clearReplyMarkup: async (chatIdInput, messageIdInput, opts = {}) => + await editMessageReplyMarkupTelegram(chatIdInput, messageIdInput, [], opts), + deleteMessage: deleteMessageTelegram, + renameTopic: renameForumTopicTelegram, + pinMessage: pinMessageTelegram, + unpinMessage: unpinMessageTelegram, + }, }, signal: { probeSignal, diff --git a/src/plugins/runtime/runtime-discord-typing.test.ts b/src/plugins/runtime/runtime-discord-typing.test.ts new file mode 100644 index 00000000000..1eb5b6fd315 --- /dev/null +++ b/src/plugins/runtime/runtime-discord-typing.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createDiscordTypingLease } from "./runtime-discord-typing.js"; + +describe("createDiscordTypingLease", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("pulses immediately and keeps leases independent", async () => { + vi.useFakeTimers(); + const pulse = vi.fn(async () => undefined); + + const leaseA = await createDiscordTypingLease({ + channelId: "123", + intervalMs: 2_000, + pulse, + }); + const leaseB = await createDiscordTypingLease({ + channelId: "123", + intervalMs: 2_000, + pulse, + }); + + expect(pulse).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(2_000); + expect(pulse).toHaveBeenCalledTimes(4); + + leaseA.stop(); + await vi.advanceTimersByTimeAsync(2_000); + expect(pulse).toHaveBeenCalledTimes(5); + + await leaseB.refresh(); + expect(pulse).toHaveBeenCalledTimes(6); + + leaseB.stop(); + }); + + it("swallows background pulse failures", async () => { + vi.useFakeTimers(); + const pulse = vi + .fn<(params: { channelId: string; accountId?: string; cfg?: unknown }) => Promise>() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("boom")); + + const lease = await createDiscordTypingLease({ + channelId: "123", + intervalMs: 2_000, + pulse, + }); + + await expect(vi.advanceTimersByTimeAsync(2_000)).resolves.toBe(vi); + expect(pulse).toHaveBeenCalledTimes(2); + + lease.stop(); + }); +}); diff --git a/src/plugins/runtime/runtime-discord-typing.ts b/src/plugins/runtime/runtime-discord-typing.ts new file mode 100644 index 00000000000..e5bed40e987 --- /dev/null +++ b/src/plugins/runtime/runtime-discord-typing.ts @@ -0,0 +1,62 @@ +import { logWarn } from "../../logger.js"; + +export type CreateDiscordTypingLeaseParams = { + channelId: string; + accountId?: string; + cfg?: ReturnType; + intervalMs?: number; + pulse: (params: { + channelId: string; + accountId?: string; + cfg?: ReturnType; + }) => Promise; +}; + +const DEFAULT_DISCORD_TYPING_INTERVAL_MS = 8_000; + +export async function createDiscordTypingLease(params: CreateDiscordTypingLeaseParams): Promise<{ + refresh: () => Promise; + stop: () => void; +}> { + const intervalMs = + typeof params.intervalMs === "number" && Number.isFinite(params.intervalMs) + ? Math.max(1_000, Math.floor(params.intervalMs)) + : DEFAULT_DISCORD_TYPING_INTERVAL_MS; + + let stopped = false; + let timer: ReturnType | null = null; + + const pulse = async () => { + if (stopped) { + return; + } + await params.pulse({ + channelId: params.channelId, + accountId: params.accountId, + cfg: params.cfg, + }); + }; + + await pulse(); + + timer = setInterval(() => { + // Background lease refreshes must never escape as unhandled rejections. + void pulse().catch((err) => { + logWarn(`plugins: discord typing pulse failed: ${String(err)}`); + }); + }, intervalMs); + timer.unref?.(); + + return { + refresh: async () => { + await pulse(); + }, + stop: () => { + stopped = true; + if (timer) { + clearInterval(timer); + timer = null; + } + }, + }; +} diff --git a/src/plugins/runtime/runtime-telegram-typing.test.ts b/src/plugins/runtime/runtime-telegram-typing.test.ts new file mode 100644 index 00000000000..3394aa1cf50 --- /dev/null +++ b/src/plugins/runtime/runtime-telegram-typing.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createTelegramTypingLease } from "./runtime-telegram-typing.js"; + +describe("createTelegramTypingLease", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("pulses immediately and keeps leases independent", async () => { + vi.useFakeTimers(); + const pulse = vi.fn(async () => undefined); + + const leaseA = await createTelegramTypingLease({ + to: "telegram:123", + intervalMs: 2_000, + pulse, + }); + const leaseB = await createTelegramTypingLease({ + to: "telegram:123", + intervalMs: 2_000, + pulse, + }); + + expect(pulse).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(2_000); + expect(pulse).toHaveBeenCalledTimes(4); + + leaseA.stop(); + await vi.advanceTimersByTimeAsync(2_000); + expect(pulse).toHaveBeenCalledTimes(5); + + await leaseB.refresh(); + expect(pulse).toHaveBeenCalledTimes(6); + + leaseB.stop(); + }); + + it("swallows background pulse failures", async () => { + vi.useFakeTimers(); + const pulse = vi + .fn< + (params: { + to: string; + accountId?: string; + cfg?: unknown; + messageThreadId?: number; + }) => Promise + >() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("boom")); + + const lease = await createTelegramTypingLease({ + to: "telegram:123", + intervalMs: 2_000, + pulse, + }); + + await expect(vi.advanceTimersByTimeAsync(2_000)).resolves.toBe(vi); + expect(pulse).toHaveBeenCalledTimes(2); + + lease.stop(); + }); + + it("falls back to the default interval for non-finite values", async () => { + vi.useFakeTimers(); + const pulse = vi.fn(async () => undefined); + + const lease = await createTelegramTypingLease({ + to: "telegram:123", + intervalMs: Number.NaN, + pulse, + }); + + expect(pulse).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(3_999); + expect(pulse).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(pulse).toHaveBeenCalledTimes(2); + + lease.stop(); + }); +}); diff --git a/src/plugins/runtime/runtime-telegram-typing.ts b/src/plugins/runtime/runtime-telegram-typing.ts new file mode 100644 index 00000000000..3a10d5f38d1 --- /dev/null +++ b/src/plugins/runtime/runtime-telegram-typing.ts @@ -0,0 +1,60 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { logWarn } from "../../logger.js"; + +export type CreateTelegramTypingLeaseParams = { + to: string; + accountId?: string; + cfg?: OpenClawConfig; + intervalMs?: number; + messageThreadId?: number; + pulse: (params: { + to: string; + accountId?: string; + cfg?: OpenClawConfig; + messageThreadId?: number; + }) => Promise; +}; + +export async function createTelegramTypingLease(params: CreateTelegramTypingLeaseParams): Promise<{ + refresh: () => Promise; + stop: () => void; +}> { + const intervalMs = + typeof params.intervalMs === "number" && Number.isFinite(params.intervalMs) + ? Math.max(1_000, Math.floor(params.intervalMs)) + : 4_000; + let stopped = false; + + const refresh = async () => { + if (stopped) { + return; + } + await params.pulse({ + to: params.to, + accountId: params.accountId, + cfg: params.cfg, + messageThreadId: params.messageThreadId, + }); + }; + + await refresh(); + + const timer = setInterval(() => { + // Background lease refreshes must never escape as unhandled rejections. + void refresh().catch((err) => { + logWarn(`plugins: telegram typing pulse failed: ${String(err)}`); + }); + }, intervalMs); + timer.unref?.(); + + return { + refresh, + stop: () => { + if (stopped) { + return; + } + stopped = true; + clearInterval(timer); + }, + }; +} diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index bf2f2387d46..f2e775b7275 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -94,9 +94,30 @@ export type PluginRuntimeChannel = { probeDiscord: typeof import("../../../extensions/discord/src/probe.js").probeDiscord; resolveChannelAllowlist: typeof import("../../../extensions/discord/src/resolve-channels.js").resolveDiscordChannelAllowlist; resolveUserAllowlist: typeof import("../../../extensions/discord/src/resolve-users.js").resolveDiscordUserAllowlist; + sendComponentMessage: typeof import("../../../extensions/discord/src/send.js").sendDiscordComponentMessage; sendMessageDiscord: typeof import("../../../extensions/discord/src/send.js").sendMessageDiscord; sendPollDiscord: typeof import("../../../extensions/discord/src/send.js").sendPollDiscord; monitorDiscordProvider: typeof import("../../../extensions/discord/src/monitor.js").monitorDiscordProvider; + typing: { + pulse: typeof import("../../../extensions/discord/src/send.js").sendTypingDiscord; + start: (params: { + channelId: string; + accountId?: string; + cfg?: ReturnType; + intervalMs?: number; + }) => Promise<{ + refresh: () => Promise; + stop: () => void; + }>; + }; + conversationActions: { + editMessage: typeof import("../../../extensions/discord/src/send.js").editMessageDiscord; + deleteMessage: typeof import("../../../extensions/discord/src/send.js").deleteMessageDiscord; + pinMessage: typeof import("../../../extensions/discord/src/send.js").pinMessageDiscord; + unpinMessage: typeof import("../../../extensions/discord/src/send.js").unpinMessageDiscord; + createThread: typeof import("../../../extensions/discord/src/send.js").createThreadDiscord; + editChannel: typeof import("../../../extensions/discord/src/send.js").editChannelDiscord; + }; }; slack: { listDirectoryGroupsLive: typeof import("../../../extensions/slack/src/directory-live.js").listSlackDirectoryGroupsLive; @@ -117,6 +138,39 @@ export type PluginRuntimeChannel = { sendPollTelegram: typeof import("../../../extensions/telegram/src/send.js").sendPollTelegram; monitorTelegramProvider: typeof import("../../../extensions/telegram/src/monitor.js").monitorTelegramProvider; messageActions: typeof import("../../channels/plugins/actions/telegram.js").telegramMessageActions; + typing: { + pulse: typeof import("../../../extensions/telegram/src/send.js").sendTypingTelegram; + start: (params: { + to: string; + accountId?: string; + cfg?: ReturnType; + intervalMs?: number; + messageThreadId?: number; + }) => Promise<{ + refresh: () => Promise; + stop: () => void; + }>; + }; + conversationActions: { + editMessage: typeof import("../../../extensions/telegram/src/send.js").editMessageTelegram; + editReplyMarkup: typeof import("../../../extensions/telegram/src/send.js").editMessageReplyMarkupTelegram; + clearReplyMarkup: ( + chatIdInput: string | number, + messageIdInput: string | number, + opts?: { + token?: string; + accountId?: string; + verbose?: boolean; + api?: Partial; + retry?: import("../../infra/retry.js").RetryConfig; + cfg?: ReturnType; + }, + ) => Promise<{ ok: true; messageId: string; chatId: string }>; + deleteMessage: typeof import("../../../extensions/telegram/src/send.js").deleteMessageTelegram; + renameTopic: typeof import("../../../extensions/telegram/src/send.js").renameForumTopicTelegram; + pinMessage: typeof import("../../../extensions/telegram/src/send.js").pinMessageTelegram; + unpinMessage: typeof import("../../../extensions/telegram/src/send.js").unpinMessageTelegram; + }; }; signal: { probeSignal: typeof import("../../../extensions/signal/src/probe.js").probeSignal; diff --git a/src/plugins/services.test.ts b/src/plugins/services.test.ts index f508396362d..3c853041ae9 100644 --- a/src/plugins/services.test.ts +++ b/src/plugins/services.test.ts @@ -19,7 +19,12 @@ import { startPluginServices } from "./services.js"; function createRegistry(services: OpenClawPluginService[]) { const registry = createEmptyPluginRegistry(); for (const service of services) { - registry.services.push({ pluginId: "plugin:test", service, source: "test" }); + registry.services.push({ + pluginId: "plugin:test", + service, + source: "test", + rootDir: "/plugins/test-plugin", + }); } return registry; } @@ -116,7 +121,9 @@ describe("startPluginServices", () => { await handle.stop(); expect(mockedLogger.error).toHaveBeenCalledWith( - expect.stringContaining("plugin service failed (service-start-fail):"), + expect.stringContaining( + "plugin service failed (service-start-fail, plugin=plugin:test, root=/plugins/test-plugin):", + ), ); expect(mockedLogger.warn).toHaveBeenCalledWith( expect.stringContaining("plugin service stop failed (service-stop-fail):"), diff --git a/src/plugins/services.ts b/src/plugins/services.ts index 751df4f8740..07746e1650a 100644 --- a/src/plugins/services.ts +++ b/src/plugins/services.ts @@ -54,7 +54,11 @@ export async function startPluginServices(params: { stop: service.stop ? () => service.stop?.(serviceContext) : undefined, }); } catch (err) { - log.error(`plugin service failed (${service.id}): ${String(err)}`); + const error = err as Error; + const stack = error?.stack?.trim(); + log.error( + `plugin service failed (${service.id}, plugin=${entry.pluginId}, root=${entry.rootDir ?? "unknown"}): ${error?.message ?? String(err)}${stack ? `\n${stack}` : ""}`, + ); } } diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 404974f4fc1..19542b44c2d 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1,4 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; +import type { TopLevelComponents } from "@buape/carbon"; import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { StreamFn } from "@mariozechner/pi-agent-core"; import type { Api, Model } from "@mariozechner/pi-ai"; @@ -511,8 +512,48 @@ export type PluginCommandContext = { accountId?: string; /** Thread/topic id if available */ messageThreadId?: number; + requestConversationBinding: ( + params?: PluginConversationBindingRequestParams, + ) => Promise; + detachConversationBinding: () => Promise<{ removed: boolean }>; + getCurrentConversationBinding: () => Promise; }; +export type PluginConversationBindingRequestParams = { + summary?: string; + detachHint?: string; +}; + +export type PluginConversationBinding = { + bindingId: string; + pluginId: string; + pluginName?: string; + pluginRoot: string; + channel: string; + accountId: string; + conversationId: string; + parentConversationId?: string; + threadId?: string | number; + boundAt: number; + summary?: string; + detachHint?: string; +}; + +export type PluginConversationBindingRequestResult = + | { + status: "bound"; + binding: PluginConversationBinding; + } + | { + status: "pending"; + approvalId: string; + reply: ReplyPayload; + } + | { + status: "error"; + message: string; + }; + /** * Result returned by a plugin command handler. */ @@ -547,6 +588,111 @@ export type OpenClawPluginCommandDefinition = { handler: PluginCommandHandler; }; +export type PluginInteractiveChannel = "telegram" | "discord"; + +export type PluginInteractiveButtons = Array< + Array<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }> +>; + +export type PluginInteractiveTelegramHandlerResult = { + handled?: boolean; +} | void; + +export type PluginInteractiveTelegramHandlerContext = { + channel: "telegram"; + accountId: string; + callbackId: string; + conversationId: string; + parentConversationId?: string; + senderId?: string; + senderUsername?: string; + threadId?: number; + isGroup: boolean; + isForum: boolean; + auth: { + isAuthorizedSender: boolean; + }; + callback: { + data: string; + namespace: string; + payload: string; + messageId: number; + chatId: string; + messageText?: string; + }; + respond: { + reply: (params: { text: string; buttons?: PluginInteractiveButtons }) => Promise; + editMessage: (params: { text: string; buttons?: PluginInteractiveButtons }) => Promise; + editButtons: (params: { buttons: PluginInteractiveButtons }) => Promise; + clearButtons: () => Promise; + deleteMessage: () => Promise; + }; + requestConversationBinding: ( + params?: PluginConversationBindingRequestParams, + ) => Promise; + detachConversationBinding: () => Promise<{ removed: boolean }>; + getCurrentConversationBinding: () => Promise; +}; + +export type PluginInteractiveDiscordHandlerResult = { + handled?: boolean; +} | void; + +export type PluginInteractiveDiscordHandlerContext = { + channel: "discord"; + accountId: string; + interactionId: string; + conversationId: string; + parentConversationId?: string; + guildId?: string; + senderId?: string; + senderUsername?: string; + auth: { + isAuthorizedSender: boolean; + }; + interaction: { + kind: "button" | "select" | "modal"; + data: string; + namespace: string; + payload: string; + messageId?: string; + values?: string[]; + fields?: Array<{ id: string; name: string; values: string[] }>; + }; + respond: { + acknowledge: () => Promise; + reply: (params: { text: string; ephemeral?: boolean }) => Promise; + followUp: (params: { text: string; ephemeral?: boolean }) => Promise; + editMessage: (params: { text?: string; components?: TopLevelComponents[] }) => Promise; + clearComponents: (params?: { text?: string }) => Promise; + }; + requestConversationBinding: ( + params?: PluginConversationBindingRequestParams, + ) => Promise; + detachConversationBinding: () => Promise<{ removed: boolean }>; + getCurrentConversationBinding: () => Promise; +}; + +export type PluginInteractiveTelegramHandlerRegistration = { + channel: "telegram"; + namespace: string; + handler: ( + ctx: PluginInteractiveTelegramHandlerContext, + ) => Promise | PluginInteractiveTelegramHandlerResult; +}; + +export type PluginInteractiveDiscordHandlerRegistration = { + channel: "discord"; + namespace: string; + handler: ( + ctx: PluginInteractiveDiscordHandlerContext, + ) => Promise | PluginInteractiveDiscordHandlerResult; +}; + +export type PluginInteractiveHandlerRegistration = + | PluginInteractiveTelegramHandlerRegistration + | PluginInteractiveDiscordHandlerRegistration; + export type OpenClawPluginHttpRouteAuth = "gateway" | "plugin"; export type OpenClawPluginHttpRouteMatch = "exact" | "prefix"; @@ -611,6 +757,7 @@ export type OpenClawPluginApi = { version?: string; description?: string; source: string; + rootDir?: string; config: OpenClawConfig; pluginConfig?: Record; runtime: PluginRuntime; @@ -630,6 +777,7 @@ export type OpenClawPluginApi = { registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void; registerService: (service: OpenClawPluginService) => void; registerProvider: (provider: ProviderPlugin) => void; + registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => void; /** * Register a custom command that bypasses the LLM agent. * Plugin commands are processed before built-in commands and before agent invocation. @@ -673,6 +821,7 @@ export type PluginHookName = | "before_compaction" | "after_compaction" | "before_reset" + | "inbound_claim" | "message_received" | "message_sending" | "message_sent" @@ -699,6 +848,7 @@ export const PLUGIN_HOOK_NAMES = [ "before_compaction", "after_compaction", "before_reset", + "inbound_claim", "message_received", "message_sending", "message_sent", @@ -907,6 +1057,37 @@ export type PluginHookMessageContext = { conversationId?: string; }; +export type PluginHookInboundClaimContext = PluginHookMessageContext & { + parentConversationId?: string; + senderId?: string; + messageId?: string; +}; + +export type PluginHookInboundClaimEvent = { + content: string; + body?: string; + bodyForAgent?: string; + transcript?: string; + timestamp?: number; + channel: string; + accountId?: string; + conversationId?: string; + parentConversationId?: string; + senderId?: string; + senderName?: string; + senderUsername?: string; + threadId?: string | number; + messageId?: string; + isGroup: boolean; + commandAuthorized?: boolean; + wasMentioned?: boolean; + metadata?: Record; +}; + +export type PluginHookInboundClaimResult = { + handled: boolean; +}; + // message_received hook export type PluginHookMessageReceivedEvent = { from: string; @@ -1163,6 +1344,10 @@ export type PluginHookHandlerMap = { event: PluginHookBeforeResetEvent, ctx: PluginHookAgentContext, ) => Promise | void; + inbound_claim: ( + event: PluginHookInboundClaimEvent, + ctx: PluginHookInboundClaimContext, + ) => Promise | PluginHookInboundClaimResult | void; message_received: ( event: PluginHookMessageReceivedEvent, ctx: PluginHookMessageContext, diff --git a/src/plugins/wired-hooks-inbound-claim.test.ts b/src/plugins/wired-hooks-inbound-claim.test.ts new file mode 100644 index 00000000000..2af75392fdb --- /dev/null +++ b/src/plugins/wired-hooks-inbound-claim.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from "vitest"; +import { createHookRunner } from "./hooks.js"; +import { createMockPluginRegistry } from "./hooks.test-helpers.js"; + +describe("inbound_claim hook runner", () => { + it("stops at the first handler that claims the event", async () => { + const first = vi.fn().mockResolvedValue({ handled: true }); + const second = vi.fn().mockResolvedValue({ handled: true }); + const registry = createMockPluginRegistry([ + { hookName: "inbound_claim", handler: first }, + { hookName: "inbound_claim", handler: second }, + ]); + const runner = createHookRunner(registry); + + const result = await runner.runInboundClaim( + { + content: "who are you", + channel: "telegram", + accountId: "default", + conversationId: "123:topic:77", + isGroup: true, + }, + { + channelId: "telegram", + accountId: "default", + conversationId: "123:topic:77", + }, + ); + + expect(result).toEqual({ handled: true }); + expect(first).toHaveBeenCalledTimes(1); + expect(second).not.toHaveBeenCalled(); + }); + + it("continues to the next handler when a higher-priority handler throws", async () => { + const logger = { + warn: vi.fn(), + error: vi.fn(), + }; + const failing = vi.fn().mockRejectedValue(new Error("boom")); + const succeeding = vi.fn().mockResolvedValue({ handled: true }); + const registry = createMockPluginRegistry([ + { hookName: "inbound_claim", handler: failing }, + { hookName: "inbound_claim", handler: succeeding }, + ]); + const runner = createHookRunner(registry, { logger }); + + const result = await runner.runInboundClaim( + { + content: "hi", + channel: "telegram", + accountId: "default", + conversationId: "123", + isGroup: false, + }, + { + channelId: "telegram", + accountId: "default", + conversationId: "123", + }, + ); + + expect(result).toEqual({ handled: true }); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("inbound_claim handler from test-plugin failed: Error: boom"), + ); + expect(succeeding).toHaveBeenCalledTimes(1); + }); + + it("can target a single plugin when core already owns the binding", async () => { + const first = vi.fn().mockResolvedValue({ handled: true }); + const second = vi.fn().mockResolvedValue({ handled: true }); + const registry = createMockPluginRegistry([ + { hookName: "inbound_claim", handler: first }, + { hookName: "inbound_claim", handler: second }, + ]); + registry.typedHooks[1].pluginId = "other-plugin"; + const runner = createHookRunner(registry); + + const result = await runner.runInboundClaimForPlugin( + "test-plugin", + { + content: "who are you", + channel: "discord", + accountId: "default", + conversationId: "channel:1", + isGroup: true, + }, + { + channelId: "discord", + accountId: "default", + conversationId: "channel:1", + }, + ); + + expect(result).toEqual({ handled: true }); + expect(first).toHaveBeenCalledTimes(1); + expect(second).not.toHaveBeenCalled(); + }); + + it("reports missing_plugin when the bound plugin is not loaded", async () => { + const registry = createMockPluginRegistry([]); + registry.plugins = []; + const runner = createHookRunner(registry); + + const result = await runner.runInboundClaimForPluginOutcome( + "missing-plugin", + { + content: "who are you", + channel: "discord", + accountId: "default", + conversationId: "channel:1", + isGroup: true, + }, + { + channelId: "discord", + accountId: "default", + conversationId: "channel:1", + }, + ); + + expect(result).toEqual({ status: "missing_plugin" }); + }); + + it("reports no_handler when the plugin is loaded but has no targeted hooks", async () => { + const registry = createMockPluginRegistry([]); + const runner = createHookRunner(registry); + + const result = await runner.runInboundClaimForPluginOutcome( + "test-plugin", + { + content: "who are you", + channel: "discord", + accountId: "default", + conversationId: "channel:1", + isGroup: true, + }, + { + channelId: "discord", + accountId: "default", + conversationId: "channel:1", + }, + ); + + expect(result).toEqual({ status: "no_handler" }); + }); + + it("reports error when a targeted handler throws and none claim the event", async () => { + const logger = { + warn: vi.fn(), + error: vi.fn(), + }; + const failing = vi.fn().mockRejectedValue(new Error("boom")); + const registry = createMockPluginRegistry([{ hookName: "inbound_claim", handler: failing }]); + const runner = createHookRunner(registry, { logger }); + + const result = await runner.runInboundClaimForPluginOutcome( + "test-plugin", + { + content: "who are you", + channel: "discord", + accountId: "default", + conversationId: "channel:1", + isGroup: true, + }, + { + channelId: "discord", + accountId: "default", + conversationId: "channel:1", + }, + ); + + expect(result).toEqual({ status: "error", error: "boom" }); + }); +}); From dd40741e18527c9a24991da134f22019950faba0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 16:08:30 -0700 Subject: [PATCH 035/943] feat(plugins): add compatible bundle support --- CHANGELOG.md | 1 + docs/cli/plugins.md | 29 +- docs/docs.json | 1 + docs/gateway/configuration-reference.md | 6 +- docs/plugins/bundles.md | 245 ++++++++++ docs/plugins/manifest.md | 28 +- docs/tools/plugin.md | 129 +++-- src/agents/pi-project-settings.bundle.test.ts | 105 +++++ src/agents/pi-project-settings.test.ts | 20 + src/agents/pi-project-settings.ts | 110 ++++- src/agents/skills/plugin-skills.test.ts | 58 ++- src/cli/plugins-cli.ts | 18 +- src/config/config.plugin-validation.test.ts | 77 ++- src/config/plugin-auto-enable.test.ts | 1 + src/config/validation.ts | 3 + src/hooks/plugin-hooks.test.ts | 158 +++++++ src/hooks/plugin-hooks.ts | 95 ++++ src/hooks/workspace.ts | 17 +- src/plugins/bundle-manifest.test.ts | 201 ++++++++ src/plugins/bundle-manifest.ts | 441 ++++++++++++++++++ src/plugins/discovery.test.ts | 103 ++++ src/plugins/discovery.ts | 77 ++- src/plugins/install.test.ts | 258 +++++++++- src/plugins/install.ts | 161 ++++++- src/plugins/loader.test.ts | 125 +++++ src/plugins/loader.ts | 38 ++ src/plugins/manifest-registry.test.ts | 146 ++++++ src/plugins/manifest-registry.ts | 109 ++++- src/plugins/registry.ts | 5 + src/plugins/types.ts | 4 + 30 files changed, 2696 insertions(+), 73 deletions(-) create mode 100644 docs/plugins/bundles.md create mode 100644 src/agents/pi-project-settings.bundle.test.ts create mode 100644 src/hooks/plugin-hooks.test.ts create mode 100644 src/hooks/plugin-hooks.ts create mode 100644 src/plugins/bundle-manifest.test.ts create mode 100644 src/plugins/bundle-manifest.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6acb2fd82fb..af21fcd7c45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898. - Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs. - Plugins/providers: move OpenRouter, GitHub Copilot, and OpenAI Codex provider/runtime logic into bundled plugins, including dynamic model fallback, runtime auth exchange, stream wrappers, capability hints, and cache-TTL policy. +- Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized. ### Fixes diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 0b054f5a4aa..4d9d1e8e80d 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -1,18 +1,19 @@ --- summary: "CLI reference for `openclaw plugins` (list, install, uninstall, enable/disable, doctor)" read_when: - - You want to install or manage in-process Gateway plugins + - You want to install or manage Gateway plugins or compatible bundles - You want to debug plugin load failures title: "plugins" --- # `openclaw plugins` -Manage Gateway plugins/extensions (loaded in-process). +Manage Gateway plugins/extensions and compatible bundles. Related: - Plugin system: [Plugins](/tools/plugin) +- Bundle compatibility: [Plugin bundles](/plugins/bundles) - Plugin manifest + schema: [Plugin manifest](/plugins/manifest) - Security hardening: [Security](/gateway/security) @@ -32,9 +33,13 @@ openclaw plugins update --all Bundled plugins ship with OpenClaw but start disabled. Use `plugins enable` to activate them. -All plugins must ship a `openclaw.plugin.json` file with an inline JSON Schema -(`configSchema`, even if empty). Missing/invalid manifests or schemas prevent -the plugin from loading and fail config validation. +Native OpenClaw plugins must ship `openclaw.plugin.json` with an inline JSON +Schema (`configSchema`, even if empty). Compatible bundles use their own bundle +manifests instead. + +`plugins list` shows `Format: openclaw` or `Format: bundle`. Verbose list/info +output also shows the bundle subtype (`codex`, `claude`, or `cursor`) plus detected bundle +capabilities. ### Install @@ -60,6 +65,20 @@ name, use an explicit scoped spec (for example `@scope/diffs`). Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`. +For local paths and archives, OpenClaw auto-detects: + +- native OpenClaw plugins (`openclaw.plugin.json`) +- Codex-compatible bundles (`.codex-plugin/plugin.json`) +- Claude-compatible bundles (`.claude-plugin/plugin.json` or the default Claude + component layout) +- Cursor-compatible bundles (`.cursor-plugin/plugin.json`) + +Compatible bundles install into the normal extensions root and participate in +the same list/info/enable/disable flow. Today, bundle skills, Claude +command-skills, Claude `settings.json` defaults, Cursor command-skills, and compatible Codex hook +directories are supported; other detected bundle capabilities are shown in +diagnostics/info but are not yet wired into runtime execution. + Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`): ```bash diff --git a/docs/docs.json b/docs/docs.json index 8855a7335d6..229699ec37e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1046,6 +1046,7 @@ "group": "Extensions", "pages": [ "plugins/community", + "plugins/bundles", "plugins/voice-call", "plugins/zalouser", "plugins/manifest", diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index a72ad7d76da..78e58edc085 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2323,12 +2323,14 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.5 via LM Studio ``` - Loaded from `~/.openclaw/extensions`, `/.openclaw/extensions`, plus `plugins.load.paths`. +- Discovery accepts native OpenClaw plugins plus compatible Codex bundles and Claude bundles, including manifestless Claude default-layout bundles. - **Config changes require a gateway restart.** - `allow`: optional allowlist (only listed plugins load). `deny` wins. - `plugins.entries..apiKey`: plugin-level API key convenience field (when supported by the plugin). - `plugins.entries..env`: plugin-scoped env var map. -- `plugins.entries..hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`. -- `plugins.entries..config`: plugin-defined config object (validated by plugin schema). +- `plugins.entries..hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`. Applies to native plugin hooks and supported bundle-provided hook directories. +- `plugins.entries..config`: plugin-defined config object (validated by native OpenClaw plugin schema when available). +- Enabled Claude bundle plugins can also contribute embedded Pi defaults from `settings.json`; OpenClaw applies those as sanitized agent settings, not as raw OpenClaw config patches. - `plugins.slots.memory`: pick the active memory plugin id, or `"none"` to disable memory plugins. - `plugins.slots.contextEngine`: pick the active context engine plugin id; defaults to `"legacy"` unless you install and select another engine. - `plugins.installs`: CLI-managed install metadata used by `openclaw plugins update`. diff --git a/docs/plugins/bundles.md b/docs/plugins/bundles.md new file mode 100644 index 00000000000..1756baca71d --- /dev/null +++ b/docs/plugins/bundles.md @@ -0,0 +1,245 @@ +--- +summary: "Compatible Codex/Claude bundle formats: detection, mapping, and current OpenClaw support" +read_when: + - You want to install or debug a Codex/Claude-compatible bundle + - You need to understand how OpenClaw maps bundle content into native features + - You are documenting bundle compatibility or current support limits +title: "Plugin Bundles" +--- + +# Plugin bundles + +OpenClaw supports three **compatible bundle formats** in addition to native +OpenClaw plugins: + +- Codex bundles +- Claude bundles +- Cursor bundles + +OpenClaw shows both as `Format: bundle` in `openclaw plugins list`. Verbose +output and `openclaw plugins info ` also show the bundle subtype +(`codex`, `claude`, or `cursor`). + +Related: + +- Plugin system overview: [Plugins](/tools/plugin) +- CLI install/list flows: [plugins](/cli/plugins) +- Native manifest schema: [Plugin manifest](/plugins/manifest) + +## What a bundle is + +A bundle is a **content/metadata pack**, not a native in-process OpenClaw +plugin. + +Today, OpenClaw does **not** execute bundle runtime code in-process. Instead, +it detects known bundle files, reads the metadata, and maps supported bundle +content into native OpenClaw surfaces such as skills, hook packs, and embedded +Pi settings. + +That is the main trust boundary: + +- native OpenClaw plugin: runtime module executes in-process +- bundle: metadata/content pack, with selective feature mapping + +## Supported bundle formats + +### Codex bundles + +Typical markers: + +- `.codex-plugin/plugin.json` +- optional `skills/` +- optional `hooks/` +- optional `.mcp.json` +- optional `.app.json` + +### Claude bundles + +OpenClaw supports both: + +- manifest-based Claude bundles: `.claude-plugin/plugin.json` +- manifestless Claude bundles that use the default component layout + +Default Claude layout markers OpenClaw recognizes: + +- `skills/` +- `commands/` +- `agents/` +- `hooks/hooks.json` +- `.mcp.json` +- `.lsp.json` +- `settings.json` + +### Cursor bundles + +Typical markers: + +- `.cursor-plugin/plugin.json` +- optional `skills/` +- optional `.cursor/commands/` +- optional `.cursor/agents/` +- optional `.cursor/rules/` +- optional `.cursor/hooks.json` +- optional `.mcp.json` + +## Detection order + +OpenClaw prefers native OpenClaw plugin/package layouts before bundle handling. + +Practical effect: + +- `openclaw.plugin.json` wins over bundle detection +- package installs with valid `package.json` + `openclaw.extensions` use the + native install path +- if a directory contains both native and bundle metadata, OpenClaw treats it + as native first + +That avoids partially installing a dual-format package as a bundle and then +loading it later as a native plugin. + +## Current mapping + +OpenClaw normalizes bundle metadata into one internal bundle record, then maps +supported surfaces into existing native behavior. + +### Supported now + +#### Skills + +- Codex `skills` roots load as normal OpenClaw skill roots +- Claude `skills` roots load as normal OpenClaw skill roots +- Claude `commands` roots are treated as additional skill roots +- Cursor `skills` roots load as normal OpenClaw skill roots +- Cursor `.cursor/commands` roots are treated as additional skill roots + +This means Claude markdown command files work through the normal OpenClaw skill +loader. Cursor command markdown works through the same path. + +#### Hook packs + +- Codex `hooks` roots work **only** when they use the normal OpenClaw hook-pack + layout: + - `HOOK.md` + - `handler.ts` or `handler.js` + +#### Embedded Pi settings + +- Claude `settings.json` is imported as default embedded Pi settings when the + bundle is enabled +- OpenClaw sanitizes shell override keys before applying them + +Sanitized keys: + +- `shellPath` +- `shellCommandPrefix` + +### Detected but not executed + +These surfaces are detected, shown in bundle capabilities, and may appear in +diagnostics/info output, but OpenClaw does not run them yet: + +- Claude `agents` +- Claude `hooks.json` automation +- Claude `mcpServers` +- Claude `lspServers` +- Claude `outputStyles` +- Cursor `.cursor/agents` +- Cursor `.cursor/hooks.json` +- Cursor `.cursor/rules` +- Cursor `mcpServers` +- Codex inline/app metadata beyond capability reporting + +## Claude path behavior + +Claude bundle manifests can declare custom component paths. OpenClaw treats +those paths as **additive**, not replacing defaults. + +Currently recognized custom path keys: + +- `skills` +- `commands` +- `agents` +- `hooks` +- `mcpServers` +- `lspServers` +- `outputStyles` + +Examples: + +- default `commands/` plus manifest `commands: "extra-commands"` => + OpenClaw scans both +- default `skills/` plus manifest `skills: ["team-skills"]` => + OpenClaw scans both + +## Capability reporting + +`openclaw plugins info ` shows bundle capabilities from the normalized +bundle record. + +Supported capabilities are loaded quietly. Unsupported capabilities produce a +warning such as: + +```text +bundle capability detected but not wired into OpenClaw yet: agents +``` + +Current exceptions: + +- Claude `commands` is considered supported because it maps to skills +- Claude `settings` is considered supported because it maps to embedded Pi settings +- Cursor `commands` is considered supported because it maps to skills +- Codex `hooks` is considered supported only for OpenClaw hook-pack layouts + +## Security model + +Bundle support is intentionally narrower than native plugin support. + +Current behavior: + +- bundle discovery reads files inside the plugin root with boundary checks +- skills and hook-pack paths must stay inside the plugin root +- bundle settings files are read with the same boundary checks +- OpenClaw does not execute arbitrary bundle runtime code in-process + +This makes bundle support safer by default than native plugin modules, but you +should still treat third-party bundles as trusted content for the features they +do expose. + +## Install examples + +```bash +openclaw plugins install ./my-codex-bundle +openclaw plugins install ./my-claude-bundle +openclaw plugins install ./my-cursor-bundle +openclaw plugins install ./my-bundle.tgz +openclaw plugins info my-bundle +``` + +If the directory is a native OpenClaw plugin/package, the native install path +still wins. + +## Troubleshooting + +### Bundle is detected but capabilities do not run + +Check `openclaw plugins info `. + +If the capability is listed but OpenClaw says it is not wired yet, that is a +real product limit, not a broken install. + +### Claude command files do not appear + +Make sure the bundle is enabled and the markdown files are inside a detected +`commands` root or `skills` root. + +### Claude settings do not apply + +Current support is limited to embedded Pi settings from `settings.json`. +OpenClaw does not treat bundle settings as raw OpenClaw config patches. + +### Claude hooks do not execute + +`hooks/hooks.json` is only detected today. + +If you need runnable bundle hooks today, use the normal OpenClaw hook-pack +layout through a supported Codex hook root or ship a native OpenClaw plugin. diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index d23f036880a..9c266744b71 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -8,10 +8,28 @@ title: "Plugin Manifest" # Plugin manifest (openclaw.plugin.json) -Every plugin **must** ship a `openclaw.plugin.json` file in the **plugin root**. -OpenClaw uses this manifest to validate configuration **without executing plugin -code**. Missing or invalid manifests are treated as plugin errors and block -config validation. +This page is for the **native OpenClaw plugin manifest** only. + +For compatible bundle layouts, see [Plugin bundles](/plugins/bundles). + +Compatible bundle formats use different manifest files: + +- Codex bundle: `.codex-plugin/plugin.json` +- Claude bundle: `.claude-plugin/plugin.json` or the default Claude component + layout without a manifest +- Cursor bundle: `.cursor-plugin/plugin.json` + +OpenClaw auto-detects those bundle layouts too, but they are not validated +against the `openclaw.plugin.json` schema described here. + +For compatible bundles, OpenClaw currently reads bundle metadata plus declared +skill roots, Claude command roots, Claude bundle `settings.json` defaults, and +supported hook packs when the layout matches OpenClaw runtime expectations. + +Every native OpenClaw plugin **must** ship a `openclaw.plugin.json` file in the +**plugin root**. OpenClaw uses this manifest to validate configuration +**without executing plugin code**. Missing or invalid manifests are treated as +plugin errors and block config validation. See the full plugin system guide: [Plugins](/tools/plugin). @@ -63,7 +81,7 @@ Optional keys: ## Notes -- The manifest is **required for all plugins**, including local filesystem loads. +- The manifest is **required for native OpenClaw plugins**, including local filesystem loads. - Runtime still loads the plugin module separately; the manifest is only for discovery + validation. - Exclusive plugin kinds are selected through `plugins.slots.*`. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index dbbd1c03d39..d9026e5e4fc 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -3,6 +3,7 @@ summary: "OpenClaw plugins/extensions: discovery, config, and safety" read_when: - Adding or modifying plugins/extensions - Documenting plugin install or load rules + - Working with Codex/Claude-compatible plugin bundles title: "Plugins" --- @@ -10,8 +11,13 @@ title: "Plugins" ## Quick start (new to plugins?) -A plugin is just a **small code module** that extends OpenClaw with extra -features (commands, tools, and Gateway RPC). +A plugin is either: + +- a native **OpenClaw plugin** (`openclaw.plugin.json` + runtime module), or +- a compatible **bundle** (`.codex-plugin/plugin.json` or `.claude-plugin/plugin.json`) + +Both show up under `openclaw plugins`, but only native OpenClaw plugins execute +runtime code in-process. Most of the time, you’ll use plugins when you want a feature that’s not built into core OpenClaw yet (or you want to keep optional features out of your main @@ -42,6 +48,14 @@ prerelease tag such as `@beta`/`@rc` or an exact prerelease version. See [Voice Call](/plugins/voice-call) for a concrete example plugin. Looking for third-party listings? See [Community plugins](/plugins/community). +Need the bundle compatibility details? See [Plugin bundles](/plugins/bundles). + +For compatible bundles, install from a local directory or archive: + +```bash +openclaw plugins install ./my-bundle +openclaw plugins install ./my-bundle.tgz +``` ## Architecture @@ -49,14 +63,15 @@ OpenClaw's plugin system has four layers: 1. **Manifest + discovery** OpenClaw finds candidate plugins from configured paths, workspace roots, - global extension roots, and bundled extensions. Discovery reads - `openclaw.plugin.json` plus package metadata first. + global extension roots, and bundled extensions. Discovery reads native + `openclaw.plugin.json` manifests plus supported bundle manifests first. 2. **Enablement + validation** Core decides whether a discovered plugin is enabled, disabled, blocked, or selected for an exclusive slot such as memory. 3. **Runtime loading** - Enabled plugins are loaded in-process via jiti and register capabilities into - a central registry. + Native OpenClaw plugins are loaded in-process via jiti and register + capabilities into a central registry. Compatible bundles are normalized into + registry records without importing runtime code. 4. **Surface consumption** The rest of OpenClaw reads the registry to expose tools, channels, provider setup, hooks, HTTP routes, CLI commands, and services. @@ -65,22 +80,68 @@ The important design boundary: - discovery + config validation should work from **manifest/schema metadata** without executing plugin code -- runtime behavior comes from the plugin module's `register(api)` path +- native runtime behavior comes from the plugin module's `register(api)` path That split lets OpenClaw validate config, explain missing/disabled plugins, and build UI/schema hints before the full runtime is active. +## Compatible bundles + +OpenClaw also recognizes two compatible external bundle layouts: + +- Codex-style bundles: `.codex-plugin/plugin.json` +- Claude-style bundles: `.claude-plugin/plugin.json` or the default Claude + component layout without a manifest +- Cursor-style bundles: `.cursor-plugin/plugin.json` + +They are shown in the plugin list as `format=bundle`, with a subtype of +`codex` or `claude` in verbose/info output. + +See [Plugin bundles](/plugins/bundles) for the exact detection rules, mapping +behavior, and current support matrix. + +Today, OpenClaw treats these as **capability packs**, not native runtime +plugins: + +- supported now: bundled `skills` +- supported now: Claude `commands/` markdown roots, mapped into the normal + OpenClaw skill loader +- supported now: Claude bundle `settings.json` defaults for embedded Pi agent + settings (with shell override keys sanitized) +- supported now: Cursor `.cursor/commands/*.md` roots, mapped into the normal + OpenClaw skill loader +- supported now: Codex bundle hook directories that use the OpenClaw hook-pack + layout (`HOOK.md` + `handler.ts`/`handler.js`) +- detected but not wired yet: other declared bundle capabilities such as + agents, Claude hook automation, Cursor rules/hooks/MCP metadata, MCP/app/LSP + metadata, output styles + +That means bundle install/discovery/list/info/enablement all work, and bundle +skills, Claude command-skills, Claude bundle settings defaults, and compatible +Codex hook directories load when the bundle is enabled, but bundle runtime code +is not executed in-process. + +Bundle hook support is limited to the normal OpenClaw hook directory format +(`HOOK.md` plus `handler.ts`/`handler.js` under the declared hook roots). +Vendor-specific shell/JSON hook runtimes, including Claude `hooks.json`, are +only detected today and are not executed directly. + ## Execution model -Plugins run **in-process** with the Gateway. They are not sandboxed. A loaded -plugin has the same process-level trust boundary as core code. +Native OpenClaw plugins run **in-process** with the Gateway. They are not +sandboxed. A loaded native plugin has the same process-level trust boundary as +core code. Implications: -- a plugin can register tools, network handlers, hooks, and services -- a plugin bug can crash or destabilize the gateway -- a malicious plugin is equivalent to arbitrary code execution inside the - OpenClaw process +- a native plugin can register tools, network handlers, hooks, and services +- a native plugin bug can crash or destabilize the gateway +- a malicious native plugin is equivalent to arbitrary code execution inside + the OpenClaw process + +Compatible bundles are safer by default because OpenClaw currently treats them +as metadata/content packs. In current releases, that mostly means bundled +skills. Use allowlists and explicit install/load paths for non-bundled plugins. Treat workspace plugins as development-time code, not production defaults. @@ -111,11 +172,11 @@ Important trust note: - Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default) - Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default) -OpenClaw plugins are **TypeScript modules** loaded at runtime via jiti. **Config -validation does not execute plugin code**; it uses the plugin manifest and JSON -Schema instead. See [Plugin manifest](/plugins/manifest). +Native OpenClaw plugins are **TypeScript modules** loaded at runtime via jiti. +**Config validation does not execute plugin code**; it uses the plugin manifest +and JSON Schema instead. See [Plugin manifest](/plugins/manifest). -Plugins can register: +Native OpenClaw plugins can register: - Gateway RPC methods - Gateway HTTP routes @@ -129,7 +190,7 @@ Plugins can register: - **Skills** (by listing `skills` directories in the plugin manifest) - **Auto-reply commands** (execute without invoking the AI agent) -Plugins run **in‑process** with the Gateway, so treat them as trusted code. +Native OpenClaw plugins run **in‑process** with the Gateway, so treat them as trusted code. Tool authoring guide: [Plugin agent tools](/plugins/agent-tools). ## Provider runtime hooks @@ -268,13 +329,13 @@ api.registerProvider({ At startup, OpenClaw does roughly this: 1. discover candidate plugin roots -2. read `openclaw.plugin.json` and package metadata +2. read native or compatible bundle manifests and package metadata 3. reject unsafe candidates 4. normalize plugin config (`plugins.enabled`, `allow`, `deny`, `entries`, `slots`, `load.paths`) 5. decide enablement for each candidate -6. load enabled modules via jiti -7. call `register(api)` and collect registrations into the plugin registry +6. load enabled native modules via jiti +7. call native `register(api)` hooks and collect registrations into the plugin registry 8. expose the registry to commands/runtime surfaces The safety gates happen **before** runtime execution. Candidates are blocked @@ -286,13 +347,13 @@ ownership looks suspicious for non-bundled plugins. The manifest is the control-plane source of truth. OpenClaw uses it to: - identify the plugin -- discover declared channels/skills/config schema +- discover declared channels/skills/config schema or bundle capabilities - validate `plugins.entries..config` - augment Control UI labels/placeholders - show install/catalog metadata -The runtime module is the data-plane part. It registers actual behavior such as -hooks, tools, commands, or provider flows. +For native plugins, the runtime module is the data-plane part. It registers +actual behavior such as hooks, tools, commands, or provider flows. ### What the loader caches @@ -529,9 +590,16 @@ Hardening notes: - path ownership is suspicious for non-bundled plugins (POSIX owner is neither current uid nor root). - Loaded non-bundled plugins without install/load-path provenance emit a warning so you can pin trust (`plugins.allow`) or install tracking (`plugins.installs`). -Each plugin must include a `openclaw.plugin.json` file in its root. If a path -points at a file, the plugin root is the file's directory and must contain the -manifest. +Each native OpenClaw plugin must include a `openclaw.plugin.json` file in its +root. If a path points at a file, the plugin root is the file's directory and +must contain the manifest. + +Compatible bundles may instead provide one of: + +- `.codex-plugin/plugin.json` +- `.claude-plugin/plugin.json` + +Bundle directories are discovered from the same roots as native plugins. If multiple plugins resolve to the same id, the first match in the order above wins and lower-precedence copies are ignored. @@ -703,8 +771,9 @@ Validation rules (strict): - Unknown plugin ids in `entries`, `allow`, `deny`, or `slots` are **errors**. - Unknown `channels.` keys are **errors** unless a plugin manifest declares the channel id. -- Plugin config is validated using the JSON Schema embedded in +- Native plugin config is validated using the JSON Schema embedded in `openclaw.plugin.json` (`configSchema`). +- Compatible bundles currently do not expose native OpenClaw config schemas. - If a plugin is disabled, its config is preserved and a **warning** is emitted. ### Disabled vs missing vs invalid @@ -804,6 +873,10 @@ openclaw plugins disable openclaw plugins doctor ``` +`openclaw plugins list` shows the top-level format as `openclaw` or `bundle`. +Verbose list/info output also shows bundle subtype (`codex` or `claude`) plus +detected bundle capabilities. + `plugins update` only works for npm installs tracked under `plugins.installs`. If stored integrity metadata changes between updates, OpenClaw warns and asks for confirmation (use global `--yes` to bypass prompts). diff --git a/src/agents/pi-project-settings.bundle.test.ts b/src/agents/pi-project-settings.bundle.test.ts new file mode 100644 index 00000000000..d297b1ef3a1 --- /dev/null +++ b/src/agents/pi-project-settings.bundle.test.ts @@ -0,0 +1,105 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; +import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; + +const hoisted = vi.hoisted(() => ({ + loadPluginManifestRegistry: vi.fn(), +})); + +vi.mock("../plugins/manifest-registry.js", () => ({ + loadPluginManifestRegistry: (...args: unknown[]) => hoisted.loadPluginManifestRegistry(...args), +})); + +const { loadEnabledBundlePiSettingsSnapshot } = await import("./pi-project-settings.js"); + +const tempDirs = createTrackedTempDirs(); + +function buildRegistry(params: { + pluginRoot: string; + settingsFiles?: string[]; +}): PluginManifestRegistry { + return { + diagnostics: [], + plugins: [ + { + id: "claude-bundle", + name: "Claude Bundle", + format: "bundle", + bundleFormat: "claude", + bundleCapabilities: ["settings"], + channels: [], + providers: [], + skills: [], + settingsFiles: params.settingsFiles ?? ["settings.json"], + hooks: [], + origin: "workspace", + rootDir: params.pluginRoot, + source: params.pluginRoot, + manifestPath: path.join(params.pluginRoot, ".claude-plugin", "plugin.json"), + }, + ], + }; +} + +afterEach(async () => { + hoisted.loadPluginManifestRegistry.mockReset(); + await tempDirs.cleanup(); +}); + +describe("loadEnabledBundlePiSettingsSnapshot", () => { + it("loads sanitized settings from enabled bundle plugins", async () => { + const workspaceDir = await tempDirs.make("openclaw-workspace-"); + const pluginRoot = await tempDirs.make("openclaw-bundle-"); + await fs.writeFile( + path.join(pluginRoot, "settings.json"), + JSON.stringify({ + hideThinkingBlock: true, + shellPath: "/tmp/blocked-shell", + compaction: { keepRecentTokens: 64_000 }, + }), + "utf-8", + ); + hoisted.loadPluginManifestRegistry.mockReturnValue(buildRegistry({ pluginRoot })); + + const snapshot = loadEnabledBundlePiSettingsSnapshot({ + cwd: workspaceDir, + cfg: { + plugins: { + entries: { + "claude-bundle": { enabled: true }, + }, + }, + }, + }); + + expect(snapshot.hideThinkingBlock).toBe(true); + expect(snapshot.shellPath).toBeUndefined(); + expect(snapshot.compaction?.keepRecentTokens).toBe(64_000); + }); + + it("ignores disabled bundle plugins", async () => { + const workspaceDir = await tempDirs.make("openclaw-workspace-"); + const pluginRoot = await tempDirs.make("openclaw-bundle-"); + await fs.writeFile( + path.join(pluginRoot, "settings.json"), + JSON.stringify({ hideThinkingBlock: true }), + "utf-8", + ); + hoisted.loadPluginManifestRegistry.mockReturnValue(buildRegistry({ pluginRoot })); + + const snapshot = loadEnabledBundlePiSettingsSnapshot({ + cwd: workspaceDir, + cfg: { + plugins: { + entries: { + "claude-bundle": { enabled: false }, + }, + }, + }, + }); + + expect(snapshot).toEqual({}); + }); +}); diff --git a/src/agents/pi-project-settings.test.ts b/src/agents/pi-project-settings.test.ts index 07f86421f84..92d676b8427 100644 --- a/src/agents/pi-project-settings.test.ts +++ b/src/agents/pi-project-settings.test.ts @@ -41,6 +41,7 @@ describe("buildEmbeddedPiSettingsSnapshot", () => { it("sanitize mode strips shell path + prefix but keeps other project settings", () => { const snapshot = buildEmbeddedPiSettingsSnapshot({ globalSettings, + pluginSettings: {}, projectSettings, policy: "sanitize", }); @@ -53,6 +54,7 @@ describe("buildEmbeddedPiSettingsSnapshot", () => { it("ignore mode drops all project settings", () => { const snapshot = buildEmbeddedPiSettingsSnapshot({ globalSettings, + pluginSettings: {}, projectSettings, policy: "ignore", }); @@ -65,6 +67,7 @@ describe("buildEmbeddedPiSettingsSnapshot", () => { it("trusted mode keeps project settings as-is", () => { const snapshot = buildEmbeddedPiSettingsSnapshot({ globalSettings, + pluginSettings: {}, projectSettings, policy: "trusted", }); @@ -73,4 +76,21 @@ describe("buildEmbeddedPiSettingsSnapshot", () => { expect(snapshot.compaction?.reserveTokens).toBe(32_000); expect(snapshot.hideThinkingBlock).toBe(true); }); + + it("applies sanitized plugin settings before project settings", () => { + const snapshot = buildEmbeddedPiSettingsSnapshot({ + globalSettings, + pluginSettings: { + shellPath: "/tmp/blocked-shell", + compaction: { keepRecentTokens: 64_000 }, + hideThinkingBlock: false, + }, + projectSettings, + policy: "sanitize", + }); + expect(snapshot.shellPath).toBe("/bin/zsh"); + expect(snapshot.compaction?.keepRecentTokens).toBe(64_000); + expect(snapshot.compaction?.reserveTokens).toBe(32_000); + expect(snapshot.hideThinkingBlock).toBe(true); + }); }); diff --git a/src/agents/pi-project-settings.ts b/src/agents/pi-project-settings.ts index 7ddd9b6a1e9..8e08d11bca7 100644 --- a/src/agents/pi-project-settings.ts +++ b/src/agents/pi-project-settings.ts @@ -1,8 +1,17 @@ +import fs from "node:fs"; +import path from "node:path"; import { SettingsManager } from "@mariozechner/pi-coding-agent"; import type { OpenClawConfig } from "../config/config.js"; import { applyMergePatch } from "../config/merge-patch.js"; +import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js"; +import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; +import { isRecord } from "../utils.js"; import { applyPiCompactionSettingsFromConfig } from "./pi-settings.js"; +const log = createSubsystemLogger("embedded-pi-settings"); + export const DEFAULT_EMBEDDED_PI_PROJECT_SETTINGS_POLICY = "sanitize"; export const SANITIZED_PROJECT_PI_KEYS = ["shellPath", "shellCommandPrefix"] as const; @@ -10,15 +19,97 @@ export type EmbeddedPiProjectSettingsPolicy = "trusted" | "sanitize" | "ignore"; type PiSettingsSnapshot = ReturnType; -function sanitizeProjectSettings(settings: PiSettingsSnapshot): PiSettingsSnapshot { +function sanitizePiSettingsSnapshot(settings: PiSettingsSnapshot): PiSettingsSnapshot { const sanitized = { ...settings }; - // Never allow workspace-local settings to override shell execution behavior. + // Never allow plugin or workspace-local settings to override shell execution behavior. for (const key of SANITIZED_PROJECT_PI_KEYS) { delete sanitized[key]; } return sanitized; } +function sanitizeProjectSettings(settings: PiSettingsSnapshot): PiSettingsSnapshot { + return sanitizePiSettingsSnapshot(settings); +} + +function loadBundleSettingsFile(params: { + rootDir: string; + relativePath: string; +}): PiSettingsSnapshot | null { + const absolutePath = path.join(params.rootDir, params.relativePath); + const opened = openBoundaryFileSync({ + absolutePath, + rootPath: params.rootDir, + boundaryLabel: "plugin root", + rejectHardlinks: true, + }); + if (!opened.ok) { + log.warn(`skipping unsafe bundle settings file: ${absolutePath}`); + return null; + } + try { + const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown; + if (!isRecord(raw)) { + log.warn(`skipping bundle settings file with non-object JSON: ${absolutePath}`); + return null; + } + return sanitizePiSettingsSnapshot(raw as PiSettingsSnapshot); + } catch (error) { + log.warn(`failed to parse bundle settings file ${absolutePath}: ${String(error)}`); + return null; + } finally { + fs.closeSync(opened.fd); + } +} + +export function loadEnabledBundlePiSettingsSnapshot(params: { + cwd: string; + cfg?: OpenClawConfig; +}): PiSettingsSnapshot { + const workspaceDir = params.cwd.trim(); + if (!workspaceDir) { + return {}; + } + const registry = loadPluginManifestRegistry({ + workspaceDir, + config: params.cfg, + }); + if (registry.plugins.length === 0) { + return {}; + } + + const normalizedPlugins = normalizePluginsConfig(params.cfg?.plugins); + let snapshot: PiSettingsSnapshot = {}; + + for (const record of registry.plugins) { + const settingsFiles = record.settingsFiles ?? []; + if (record.format !== "bundle" || settingsFiles.length === 0) { + continue; + } + const enableState = resolveEffectiveEnableState({ + id: record.id, + origin: record.origin, + config: normalizedPlugins, + rootConfig: params.cfg, + }); + if (!enableState.enabled) { + continue; + } + for (const relativePath of settingsFiles) { + const bundleSettings = loadBundleSettingsFile({ + rootDir: record.rootDir, + relativePath, + }); + if (!bundleSettings) { + continue; + } + snapshot = applyMergePatch(snapshot, bundleSettings) as PiSettingsSnapshot; + } + } + + return snapshot; +} + export function resolveEmbeddedPiProjectSettingsPolicy( cfg?: OpenClawConfig, ): EmbeddedPiProjectSettingsPolicy { @@ -31,6 +122,7 @@ export function resolveEmbeddedPiProjectSettingsPolicy( export function buildEmbeddedPiSettingsSnapshot(params: { globalSettings: PiSettingsSnapshot; + pluginSettings?: PiSettingsSnapshot; projectSettings: PiSettingsSnapshot; policy: EmbeddedPiProjectSettingsPolicy; }): PiSettingsSnapshot { @@ -40,7 +132,11 @@ export function buildEmbeddedPiSettingsSnapshot(params: { : params.policy === "sanitize" ? sanitizeProjectSettings(params.projectSettings) : params.projectSettings; - return applyMergePatch(params.globalSettings, effectiveProjectSettings) as PiSettingsSnapshot; + const withPluginSettings = applyMergePatch( + params.globalSettings, + sanitizePiSettingsSnapshot(params.pluginSettings ?? {}), + ) as PiSettingsSnapshot; + return applyMergePatch(withPluginSettings, effectiveProjectSettings) as PiSettingsSnapshot; } export function createEmbeddedPiSettingsManager(params: { @@ -50,11 +146,17 @@ export function createEmbeddedPiSettingsManager(params: { }): SettingsManager { const fileSettingsManager = SettingsManager.create(params.cwd, params.agentDir); const policy = resolveEmbeddedPiProjectSettingsPolicy(params.cfg); - if (policy === "trusted") { + const pluginSettings = loadEnabledBundlePiSettingsSnapshot({ + cwd: params.cwd, + cfg: params.cfg, + }); + const hasPluginSettings = Object.keys(pluginSettings).length > 0; + if (policy === "trusted" && !hasPluginSettings) { return fileSettingsManager; } const settings = buildEmbeddedPiSettingsSnapshot({ globalSettings: fileSettingsManager.getGlobalSettings(), + pluginSettings, projectSettings: fileSettingsManager.getProjectSettings(), policy, }); diff --git a/src/agents/skills/plugin-skills.test.ts b/src/agents/skills/plugin-skills.test.ts index fd3abd6d07d..9edcd463c22 100644 --- a/src/agents/skills/plugin-skills.test.ts +++ b/src/agents/skills/plugin-skills.test.ts @@ -27,6 +27,7 @@ function buildRegistry(params: { acpxRoot: string; helperRoot: string }): Plugin channels: [], providers: [], skills: ["./skills"], + hooks: [], origin: "workspace", rootDir: params.acpxRoot, source: params.acpxRoot, @@ -38,6 +39,7 @@ function buildRegistry(params: { acpxRoot: string; helperRoot: string }): Plugin channels: [], providers: [], skills: ["./skills"], + hooks: [], origin: "workspace", rootDir: params.helperRoot, source: params.helperRoot, @@ -50,6 +52,7 @@ function buildRegistry(params: { acpxRoot: string; helperRoot: string }): Plugin function createSinglePluginRegistry(params: { pluginRoot: string; skills: string[]; + format?: "openclaw" | "bundle"; }): PluginManifestRegistry { return { diagnostics: [], @@ -57,9 +60,11 @@ function createSinglePluginRegistry(params: { { id: "helper", name: "Helper", + format: params.format, channels: [], providers: [], skills: params.skills, + hooks: [], origin: "workspace", rootDir: params.pluginRoot, source: params.pluginRoot, @@ -116,6 +121,12 @@ describe("resolvePluginSkillDirs", () => { workspaceDir, config: { acp: { enabled: acpEnabled }, + plugins: { + entries: { + acpx: { enabled: true }, + helper: { enabled: true }, + }, + }, } as OpenClawConfig, }); @@ -137,7 +148,13 @@ describe("resolvePluginSkillDirs", () => { const dirs = resolvePluginSkillDirs({ workspaceDir, - config: {} as OpenClawConfig, + config: { + plugins: { + entries: { + helper: { enabled: true }, + }, + }, + } as OpenClawConfig, }); expect(dirs).toEqual([path.resolve(pluginRoot, "skills")]); @@ -162,9 +179,46 @@ describe("resolvePluginSkillDirs", () => { const dirs = resolvePluginSkillDirs({ workspaceDir, - config: {} as OpenClawConfig, + config: { + plugins: { + entries: { + helper: { enabled: true }, + }, + }, + } as OpenClawConfig, }); expect(dirs).toEqual([]); }); + + it("resolves Claude bundle command roots through the normal plugin skill path", async () => { + const workspaceDir = await tempDirs.make("openclaw-"); + const pluginRoot = await tempDirs.make("openclaw-claude-bundle-"); + await fs.mkdir(path.join(pluginRoot, "commands"), { recursive: true }); + await fs.mkdir(path.join(pluginRoot, "skills"), { recursive: true }); + + hoisted.loadPluginManifestRegistry.mockReturnValue( + createSinglePluginRegistry({ + pluginRoot, + format: "bundle", + skills: ["./skills", "./commands"], + }), + ); + + const dirs = resolvePluginSkillDirs({ + workspaceDir, + config: { + plugins: { + entries: { + helper: { enabled: true }, + }, + }, + } as OpenClawConfig, + }); + + expect(dirs).toEqual([ + path.resolve(pluginRoot, "skills"), + path.resolve(pluginRoot, "commands"), + ]); + }); }); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index e77d7026875..d090fe7d83d 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -97,16 +97,21 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string { : plugin.description, ) : theme.muted("(no description)"); + const format = plugin.format ?? "openclaw"; if (!verbose) { - return `${name}${idSuffix} ${status} - ${desc}`; + return `${name}${idSuffix} ${status} ${theme.muted(`[${format}]`)} - ${desc}`; } const parts = [ `${name}${idSuffix} ${status}`, + ` format: ${format}`, ` source: ${theme.muted(shortenHomeInString(plugin.source))}`, ` origin: ${plugin.origin}`, ]; + if (plugin.bundleFormat) { + parts.push(` bundle format: ${plugin.bundleFormat}`); + } if (plugin.version) { parts.push(` version: ${plugin.version}`); } @@ -419,6 +424,7 @@ export function registerPluginsCli(program: Command) { return { Name: plugin.name || plugin.id, ID: plugin.name && plugin.name !== plugin.id ? plugin.id : "", + Format: plugin.format ?? "openclaw", Status: plugin.status === "loaded" ? theme.success("loaded") @@ -451,6 +457,7 @@ export function registerPluginsCli(program: Command) { columns: [ { key: "Name", header: "Name", minWidth: 14, flex: true }, { key: "ID", header: "ID", minWidth: 10, flex: true }, + { key: "Format", header: "Format", minWidth: 9 }, { key: "Status", header: "Status", minWidth: 10 }, { key: "Source", header: "Source", minWidth: 26, flex: true }, { key: "Version", header: "Version", minWidth: 8 }, @@ -499,6 +506,10 @@ export function registerPluginsCli(program: Command) { } lines.push(""); lines.push(`${theme.muted("Status:")} ${plugin.status}`); + lines.push(`${theme.muted("Format:")} ${plugin.format ?? "openclaw"}`); + if (plugin.bundleFormat) { + lines.push(`${theme.muted("Bundle format:")} ${plugin.bundleFormat}`); + } lines.push(`${theme.muted("Source:")} ${shortenHomeInString(plugin.source)}`); lines.push(`${theme.muted("Origin:")} ${plugin.origin}`); if (plugin.version) { @@ -516,6 +527,11 @@ export function registerPluginsCli(program: Command) { if (plugin.providerIds.length > 0) { lines.push(`${theme.muted("Providers:")} ${plugin.providerIds.join(", ")}`); } + if ((plugin.bundleCapabilities?.length ?? 0) > 0) { + lines.push( + `${theme.muted("Bundle capabilities:")} ${plugin.bundleCapabilities?.join(", ")}`, + ); + } if (plugin.cliCommands.length > 0) { lines.push(`${theme.muted("CLI commands:")} ${plugin.cliCommands.join(", ")}`); } diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index f7f5539eb5a..efb84acdacf 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -43,6 +43,35 @@ async function writePluginFixture(params: { ); } +async function writeBundleFixture(params: { + dir: string; + format: "codex" | "claude"; + name: string; +}) { + await mkdirSafe(params.dir); + const manifestDir = path.join( + params.dir, + params.format === "codex" ? ".codex-plugin" : ".claude-plugin", + ); + await mkdirSafe(manifestDir); + await fs.writeFile( + path.join(manifestDir, "plugin.json"), + JSON.stringify({ name: params.name }, null, 2), + "utf-8", + ); +} + +async function writeManifestlessClaudeBundleFixture(params: { dir: string }) { + await mkdirSafe(params.dir); + await mkdirSafe(path.join(params.dir, "commands")); + await fs.writeFile( + path.join(params.dir, "commands", "review.md"), + "---\ndescription: fixture\n---\n", + "utf-8", + ); + await fs.writeFile(path.join(params.dir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8"); +} + describe("config plugin validation", () => { let fixtureRoot = ""; let suiteHome = ""; @@ -50,6 +79,8 @@ describe("config plugin validation", () => { let enumPluginDir = ""; let bluebubblesPluginDir = ""; let voiceCallSchemaPluginDir = ""; + let bundlePluginDir = ""; + let manifestlessClaudeBundleDir = ""; const suiteEnv = () => ({ ...process.env, @@ -103,6 +134,16 @@ describe("config plugin validation", () => { channels: ["bluebubbles"], schema: { type: "object" }, }); + bundlePluginDir = path.join(suiteHome, "bundle-plugin"); + await writeBundleFixture({ + dir: bundlePluginDir, + format: "codex", + name: "Bundle Fixture", + }); + manifestlessClaudeBundleDir = path.join(suiteHome, "manifestless-claude-bundle"); + await writeManifestlessClaudeBundleFixture({ + dir: manifestlessClaudeBundleDir, + }); voiceCallSchemaPluginDir = path.join(suiteHome, "voice-call-schema-plugin"); const voiceCallManifestPath = path.join( process.cwd(), @@ -127,7 +168,15 @@ describe("config plugin validation", () => { validateInSuite({ plugins: { enabled: false, - load: { paths: [badPluginDir, bluebubblesPluginDir, voiceCallSchemaPluginDir] }, + load: { + paths: [ + badPluginDir, + bluebubblesPluginDir, + bundlePluginDir, + manifestlessClaudeBundleDir, + voiceCallSchemaPluginDir, + ], + }, }, }); }); @@ -252,6 +301,32 @@ describe("config plugin validation", () => { } }); + it("does not require native config schemas for enabled bundle plugins", async () => { + const res = validateInSuite({ + agents: { list: [{ id: "pi" }] }, + plugins: { + enabled: true, + load: { paths: [bundlePluginDir] }, + entries: { "bundle-fixture": { enabled: true } }, + }, + }); + + expect(res.ok).toBe(true); + }); + + it("accepts enabled manifestless Claude bundles without a native schema", async () => { + const res = validateInSuite({ + agents: { list: [{ id: "pi" }] }, + plugins: { + enabled: true, + load: { paths: [manifestlessClaudeBundleDir] }, + entries: { "manifestless-claude-bundle": { enabled: true } }, + }, + }); + + expect(res.ok).toBe(true); + }); + it("surfaces allowed enum values for plugin config diagnostics", async () => { const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index 1de11be4a1e..c289417ce53 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -62,6 +62,7 @@ function makeRegistry(plugins: Array<{ id: string; channels: string[] }>): Plugi channels: p.channels, providers: [], skills: [], + hooks: [], origin: "config" as const, rootDir: `/fake/${p.id}`, source: `/fake/${p.id}/index.js`, diff --git a/src/config/validation.ts b/src/config/validation.ts index 1486ea07182..e97bd8cbedf 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -596,6 +596,9 @@ function validateConfigObjectWithPluginsBase( }); } } + } else if (record.format === "bundle") { + // Compatible bundles currently expose no native OpenClaw config schema. + // Treat them as schema-less capability packs rather than failing validation. } else { issues.push({ path: `plugins.entries.${pluginId}`, diff --git a/src/hooks/plugin-hooks.test.ts b/src/hooks/plugin-hooks.test.ts new file mode 100644 index 00000000000..333c3a3cf39 --- /dev/null +++ b/src/hooks/plugin-hooks.test.ts @@ -0,0 +1,158 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + clearInternalHooks, + createInternalHookEvent, + triggerInternalHook, +} from "./internal-hooks.js"; +import { loadInternalHooks } from "./loader.js"; +import { loadWorkspaceHookEntries } from "./workspace.js"; + +describe("bundle plugin hooks", () => { + let fixtureRoot = ""; + let caseId = 0; + let workspaceDir = ""; + let previousBundledHooksDir: string | undefined; + + beforeAll(async () => { + fixtureRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-hooks-")); + }); + + beforeEach(async () => { + clearInternalHooks(); + workspaceDir = path.join(fixtureRoot, `case-${caseId++}`); + await fsp.mkdir(workspaceDir, { recursive: true }); + previousBundledHooksDir = process.env.OPENCLAW_BUNDLED_HOOKS_DIR; + process.env.OPENCLAW_BUNDLED_HOOKS_DIR = "/nonexistent/bundled/hooks"; + }); + + afterEach(() => { + clearInternalHooks(); + if (previousBundledHooksDir === undefined) { + delete process.env.OPENCLAW_BUNDLED_HOOKS_DIR; + } else { + process.env.OPENCLAW_BUNDLED_HOOKS_DIR = previousBundledHooksDir; + } + }); + + afterAll(async () => { + await fsp.rm(fixtureRoot, { recursive: true, force: true }); + }); + + async function writeBundleHookFixture(): Promise { + const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "sample-bundle"); + const hookDir = path.join(bundleRoot, "hooks", "bundle-hook"); + await fsp.mkdir(path.join(bundleRoot, ".codex-plugin"), { recursive: true }); + await fsp.mkdir(hookDir, { recursive: true }); + await fsp.writeFile( + path.join(bundleRoot, ".codex-plugin", "plugin.json"), + JSON.stringify({ + name: "Sample Bundle", + hooks: "hooks", + }), + "utf-8", + ); + await fsp.writeFile( + path.join(hookDir, "HOOK.md"), + [ + "---", + "name: bundle-hook", + 'description: "Bundle hook"', + 'metadata: {"openclaw":{"events":["command:new"]}}', + "---", + "", + "# Bundle hook", + "", + ].join("\n"), + "utf-8", + ); + await fsp.writeFile( + path.join(hookDir, "handler.js"), + 'export default async function(event) { event.messages.push("bundle-hook-ok"); }\n', + "utf-8", + ); + return bundleRoot; + } + + function createConfig(enabled: boolean): OpenClawConfig { + return { + hooks: { + internal: { + enabled: true, + }, + }, + plugins: { + entries: { + "sample-bundle": { + enabled, + }, + }, + }, + }; + } + + it("exposes enabled bundle hook dirs as plugin-managed hook entries", async () => { + const bundleRoot = await writeBundleHookFixture(); + + const entries = loadWorkspaceHookEntries(workspaceDir, { + config: createConfig(true), + }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.hook.name).toBe("bundle-hook"); + expect(entries[0]?.hook.source).toBe("openclaw-plugin"); + expect(entries[0]?.hook.pluginId).toBe("sample-bundle"); + expect(entries[0]?.hook.baseDir).toBe( + fs.realpathSync.native(path.join(bundleRoot, "hooks", "bundle-hook")), + ); + expect(entries[0]?.metadata?.events).toEqual(["command:new"]); + }); + + it("loads and executes enabled bundle hooks through the internal hook loader", async () => { + await writeBundleHookFixture(); + + const count = await loadInternalHooks(createConfig(true), workspaceDir); + expect(count).toBe(1); + + const event = createInternalHookEvent("command", "new", "test-session"); + await triggerInternalHook(event); + expect(event.messages).toContain("bundle-hook-ok"); + }); + + it("skips disabled bundle hooks", async () => { + await writeBundleHookFixture(); + + const entries = loadWorkspaceHookEntries(workspaceDir, { + config: createConfig(false), + }); + expect(entries).toHaveLength(0); + }); + + it("does not treat Claude hooks.json bundles as OpenClaw hook packs", async () => { + const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "claude-bundle"); + await fsp.mkdir(path.join(bundleRoot, ".claude-plugin"), { recursive: true }); + await fsp.mkdir(path.join(bundleRoot, "hooks"), { recursive: true }); + await fsp.writeFile( + path.join(bundleRoot, ".claude-plugin", "plugin.json"), + JSON.stringify({ + name: "Claude Bundle", + hooks: [{ type: "command" }], + }), + "utf-8", + ); + await fsp.writeFile(path.join(bundleRoot, "hooks", "hooks.json"), '{"hooks":[]}', "utf-8"); + + const entries = loadWorkspaceHookEntries(workspaceDir, { + config: { + hooks: { internal: { enabled: true } }, + plugins: { entries: { "claude-bundle": { enabled: true } } }, + }, + }); + + expect(entries).toHaveLength(0); + }); +}); diff --git a/src/hooks/plugin-hooks.ts b/src/hooks/plugin-hooks.ts new file mode 100644 index 00000000000..298749d2245 --- /dev/null +++ b/src/hooks/plugin-hooks.ts @@ -0,0 +1,95 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { OpenClawConfig } from "../config/config.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { + normalizePluginsConfig, + resolveEffectiveEnableState, + resolveMemorySlotDecision, +} from "../plugins/config-state.js"; +import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; +import { isPathInsideWithRealpath } from "../security/scan-paths.js"; + +const log = createSubsystemLogger("hooks"); + +export type PluginHookDirEntry = { + dir: string; + pluginId: string; +}; + +export function resolvePluginHookDirs(params: { + workspaceDir: string | undefined; + config?: OpenClawConfig; +}): PluginHookDirEntry[] { + const workspaceDir = (params.workspaceDir ?? "").trim(); + if (!workspaceDir) { + return []; + } + const registry = loadPluginManifestRegistry({ + workspaceDir, + config: params.config, + }); + if (registry.plugins.length === 0) { + return []; + } + + const normalizedPlugins = normalizePluginsConfig(params.config?.plugins); + const memorySlot = normalizedPlugins.slots.memory; + let selectedMemoryPluginId: string | null = null; + const seen = new Set(); + const resolved: PluginHookDirEntry[] = []; + + for (const record of registry.plugins) { + if (!record.hooks || record.hooks.length === 0) { + continue; + } + const enableState = resolveEffectiveEnableState({ + id: record.id, + origin: record.origin, + config: normalizedPlugins, + rootConfig: params.config, + }); + if (!enableState.enabled) { + continue; + } + + const memoryDecision = resolveMemorySlotDecision({ + id: record.id, + kind: record.kind, + slot: memorySlot, + selectedId: selectedMemoryPluginId, + }); + if (!memoryDecision.enabled) { + continue; + } + if (memoryDecision.selected && record.kind === "memory") { + selectedMemoryPluginId = record.id; + } + + for (const raw of record.hooks) { + const trimmed = raw.trim(); + if (!trimmed) { + continue; + } + const candidate = path.resolve(record.rootDir, trimmed); + if (!fs.existsSync(candidate)) { + log.warn(`plugin hook path not found (${record.id}): ${candidate}`); + continue; + } + if (!isPathInsideWithRealpath(record.rootDir, candidate, { requireRealpath: true })) { + log.warn(`plugin hook path escapes plugin root (${record.id}): ${candidate}`); + continue; + } + if (seen.has(candidate)) { + continue; + } + seen.add(candidate); + resolved.push({ + dir: candidate, + pluginId: record.id, + }); + } + } + + return resolved; +} diff --git a/src/hooks/workspace.ts b/src/hooks/workspace.ts index 56e2fc05339..d22c0183ce3 100644 --- a/src/hooks/workspace.ts +++ b/src/hooks/workspace.ts @@ -13,6 +13,7 @@ import { resolveOpenClawMetadata, resolveHookInvocationPolicy, } from "./frontmatter.js"; +import { resolvePluginHookDirs } from "./plugin-hooks.js"; import type { Hook, HookEligibilityContext, @@ -242,6 +243,10 @@ function loadHookEntries( const extraDirs = extraDirsRaw .map((d) => (typeof d === "string" ? d.trim() : "")) .filter(Boolean); + const pluginHookDirs = resolvePluginHookDirs({ + workspaceDir, + config: opts?.config, + }); const bundledHooks = bundledHooksDir ? loadHooksFromDir({ @@ -256,6 +261,13 @@ function loadHookEntries( source: "openclaw-workspace", // Extra dirs treated as workspace }); }); + const pluginHooks = pluginHookDirs.flatMap(({ dir, pluginId }) => + loadHooksFromDir({ + dir, + source: "openclaw-plugin", + pluginId, + }), + ); const managedHooks = loadHooksFromDir({ dir: managedHooksDir, source: "openclaw-managed", @@ -266,13 +278,16 @@ function loadHookEntries( }); const merged = new Map(); - // Precedence: extra < bundled < managed < workspace (workspace wins) + // Precedence: extra < bundled < plugin < managed < workspace (workspace wins) for (const hook of extraHooks) { merged.set(hook.name, hook); } for (const hook of bundledHooks) { merged.set(hook.name, hook); } + for (const hook of pluginHooks) { + merged.set(hook.name, hook); + } for (const hook of managedHooks) { merged.set(hook.name, hook); } diff --git a/src/plugins/bundle-manifest.test.ts b/src/plugins/bundle-manifest.test.ts new file mode 100644 index 00000000000..f1ad13035ee --- /dev/null +++ b/src/plugins/bundle-manifest.test.ts @@ -0,0 +1,201 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, + CODEX_BUNDLE_MANIFEST_RELATIVE_PATH, + CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH, + detectBundleManifestFormat, + loadBundleManifest, +} from "./bundle-manifest.js"; +import { + cleanupTrackedTempDirs, + makeTrackedTempDir, + mkdirSafeDir, +} from "./test-helpers/fs-fixtures.js"; + +const tempDirs: string[] = []; + +function makeTempDir() { + return makeTrackedTempDir("openclaw-bundle-manifest", tempDirs); +} + +const mkdirSafe = mkdirSafeDir; + +afterEach(() => { + cleanupTrackedTempDirs(tempDirs); +}); + +describe("bundle manifest parsing", () => { + it("detects and loads Codex bundle manifests", () => { + const rootDir = makeTempDir(); + mkdirSafe(path.join(rootDir, ".codex-plugin")); + mkdirSafe(path.join(rootDir, "skills")); + mkdirSafe(path.join(rootDir, "hooks")); + fs.writeFileSync( + path.join(rootDir, CODEX_BUNDLE_MANIFEST_RELATIVE_PATH), + JSON.stringify({ + name: "Sample Bundle", + description: "Codex fixture", + skills: "skills", + hooks: "hooks", + mcpServers: { + sample: { + command: "node", + args: ["server.js"], + }, + }, + apps: { + sample: { + title: "Sample App", + }, + }, + }), + "utf-8", + ); + + expect(detectBundleManifestFormat(rootDir)).toBe("codex"); + const result = loadBundleManifest({ rootDir, bundleFormat: "codex" }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.manifest).toMatchObject({ + id: "sample-bundle", + name: "Sample Bundle", + description: "Codex fixture", + bundleFormat: "codex", + skills: ["skills"], + hooks: ["hooks"], + capabilities: expect.arrayContaining(["hooks", "skills", "mcpServers", "apps"]), + }); + }); + + it("detects and loads Claude bundle manifests from the component layout", () => { + const rootDir = makeTempDir(); + mkdirSafe(path.join(rootDir, ".claude-plugin")); + mkdirSafe(path.join(rootDir, "skill-packs", "starter")); + mkdirSafe(path.join(rootDir, "commands-pack")); + mkdirSafe(path.join(rootDir, "agents-pack")); + mkdirSafe(path.join(rootDir, "hooks-pack")); + mkdirSafe(path.join(rootDir, "mcp")); + mkdirSafe(path.join(rootDir, "lsp")); + mkdirSafe(path.join(rootDir, "styles")); + mkdirSafe(path.join(rootDir, "hooks")); + fs.writeFileSync(path.join(rootDir, "hooks", "hooks.json"), '{"hooks":[]}', "utf-8"); + fs.writeFileSync(path.join(rootDir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8"); + fs.writeFileSync( + path.join(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH), + JSON.stringify({ + name: "Claude Sample", + description: "Claude fixture", + skills: ["skill-packs/starter"], + commands: "commands-pack", + agents: "agents-pack", + hooks: "hooks-pack", + mcpServers: "mcp", + lspServers: "lsp", + outputStyles: "styles", + }), + "utf-8", + ); + + expect(detectBundleManifestFormat(rootDir)).toBe("claude"); + const result = loadBundleManifest({ rootDir, bundleFormat: "claude" }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.manifest).toMatchObject({ + id: "claude-sample", + name: "Claude Sample", + description: "Claude fixture", + bundleFormat: "claude", + skills: ["skill-packs/starter", "commands-pack"], + settingsFiles: ["settings.json"], + hooks: [], + capabilities: expect.arrayContaining([ + "hooks", + "skills", + "commands", + "agents", + "mcpServers", + "lspServers", + "outputStyles", + "settings", + ]), + }); + }); + + it("detects and loads Cursor bundle manifests", () => { + const rootDir = makeTempDir(); + mkdirSafe(path.join(rootDir, ".cursor-plugin")); + mkdirSafe(path.join(rootDir, "skills")); + mkdirSafe(path.join(rootDir, ".cursor", "commands")); + mkdirSafe(path.join(rootDir, ".cursor", "rules")); + mkdirSafe(path.join(rootDir, ".cursor", "agents")); + fs.writeFileSync(path.join(rootDir, ".cursor", "hooks.json"), '{"hooks":[]}', "utf-8"); + fs.writeFileSync( + path.join(rootDir, CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH), + JSON.stringify({ + name: "Cursor Sample", + description: "Cursor fixture", + mcpServers: "./.mcp.json", + }), + "utf-8", + ); + fs.writeFileSync(path.join(rootDir, ".mcp.json"), '{"servers":{}}', "utf-8"); + + expect(detectBundleManifestFormat(rootDir)).toBe("cursor"); + const result = loadBundleManifest({ rootDir, bundleFormat: "cursor" }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.manifest).toMatchObject({ + id: "cursor-sample", + name: "Cursor Sample", + description: "Cursor fixture", + bundleFormat: "cursor", + skills: ["skills", ".cursor/commands"], + hooks: [], + capabilities: expect.arrayContaining([ + "skills", + "commands", + "agents", + "rules", + "hooks", + "mcpServers", + ]), + }); + }); + + it("detects manifestless Claude bundles from the default layout", () => { + const rootDir = makeTempDir(); + mkdirSafe(path.join(rootDir, "commands")); + mkdirSafe(path.join(rootDir, "skills")); + fs.writeFileSync(path.join(rootDir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8"); + + expect(detectBundleManifestFormat(rootDir)).toBe("claude"); + const result = loadBundleManifest({ rootDir, bundleFormat: "claude" }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(result.manifest.id).toBe(path.basename(rootDir).toLowerCase()); + expect(result.manifest.skills).toEqual(["skills", "commands"]); + expect(result.manifest.settingsFiles).toEqual(["settings.json"]); + expect(result.manifest.capabilities).toEqual( + expect.arrayContaining(["skills", "commands", "settings"]), + ); + }); + + it("does not misclassify native index plugins as manifestless Claude bundles", () => { + const rootDir = makeTempDir(); + mkdirSafe(path.join(rootDir, "commands")); + fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {}", "utf-8"); + + expect(detectBundleManifestFormat(rootDir)).toBeNull(); + }); +}); diff --git a/src/plugins/bundle-manifest.ts b/src/plugins/bundle-manifest.ts new file mode 100644 index 00000000000..981eb9fd3a6 --- /dev/null +++ b/src/plugins/bundle-manifest.ts @@ -0,0 +1,441 @@ +import fs from "node:fs"; +import path from "node:path"; +import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; +import { isRecord } from "../utils.js"; +import { DEFAULT_PLUGIN_ENTRY_CANDIDATES, PLUGIN_MANIFEST_FILENAME } from "./manifest.js"; +import type { PluginBundleFormat } from "./types.js"; + +export const CODEX_BUNDLE_MANIFEST_RELATIVE_PATH = ".codex-plugin/plugin.json"; +export const CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH = ".claude-plugin/plugin.json"; +export const CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH = ".cursor-plugin/plugin.json"; + +export type BundlePluginManifest = { + id: string; + name?: string; + description?: string; + version?: string; + skills: string[]; + settingsFiles?: string[]; + // Only include hook roots that OpenClaw can execute via HOOK.md + handler files. + hooks: string[]; + bundleFormat: PluginBundleFormat; + capabilities: string[]; +}; + +export type BundleManifestLoadResult = + | { ok: true; manifest: BundlePluginManifest; manifestPath: string } + | { ok: false; error: string; manifestPath: string }; + +type BundleManifestFileLoadResult = + | { ok: true; raw: Record; manifestPath: string } + | { ok: false; error: string; manifestPath: string }; + +function normalizeString(value: unknown): string | undefined { + const trimmed = typeof value === "string" ? value.trim() : ""; + return trimmed || undefined; +} + +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 normalizeBundlePathList(value: unknown): string[] { + return Array.from(new Set(normalizePathList(value))); +} + +function mergeBundlePathLists(...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 hasInlineCapabilityValue(value: unknown): boolean { + if (typeof value === "string") { + return value.trim().length > 0; + } + if (Array.isArray(value)) { + return value.length > 0; + } + if (isRecord(value)) { + return Object.keys(value).length > 0; + } + return value === true; +} + +function slugifyPluginId(raw: string | undefined, rootDir: string): string { + const fallback = path.basename(rootDir); + const source = (raw?.trim() || fallback).toLowerCase(); + const slug = source + .replace(/[^a-z0-9]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug || "bundle-plugin"; +} + +function loadBundleManifestFile(params: { + rootDir: string; + manifestRelativePath: string; + rejectHardlinks: boolean; + allowMissing?: boolean; +}): BundleManifestFileLoadResult { + const manifestPath = path.join(params.rootDir, params.manifestRelativePath); + const opened = openBoundaryFileSync({ + absolutePath: manifestPath, + rootPath: params.rootDir, + boundaryLabel: "plugin root", + rejectHardlinks: params.rejectHardlinks, + }); + if (!opened.ok) { + if (opened.reason === "path") { + if (params.allowMissing) { + return { ok: true, raw: {}, manifestPath }; + } + return { ok: false, error: `plugin manifest not found: ${manifestPath}`, manifestPath }; + } + return { + ok: false, + error: `unsafe plugin manifest path: ${manifestPath} (${opened.reason})`, + manifestPath, + }; + } + try { + const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown; + if (!isRecord(raw)) { + return { ok: false, error: "plugin manifest must be an object", manifestPath }; + } + return { ok: true, raw, manifestPath }; + } catch (err) { + return { + ok: false, + error: `failed to parse plugin manifest: ${String(err)}`, + manifestPath, + }; + } finally { + fs.closeSync(opened.fd); + } +} + +function resolveCodexSkillDirs(raw: Record, rootDir: string): string[] { + const declared = normalizeBundlePathList(raw.skills); + if (declared.length > 0) { + return declared; + } + return fs.existsSync(path.join(rootDir, "skills")) ? ["skills"] : []; +} + +function resolveCodexHookDirs(raw: Record, rootDir: string): string[] { + const declared = normalizeBundlePathList(raw.hooks); + if (declared.length > 0) { + return declared; + } + return fs.existsSync(path.join(rootDir, "hooks")) ? ["hooks"] : []; +} + +function resolveCursorSkillsRootDirs(raw: Record, rootDir: string): string[] { + const declared = normalizeBundlePathList(raw.skills); + const defaults = fs.existsSync(path.join(rootDir, "skills")) ? ["skills"] : []; + return mergeBundlePathLists(defaults, declared); +} + +function resolveCursorCommandRootDirs(raw: Record, rootDir: string): string[] { + const declared = normalizeBundlePathList(raw.commands); + const defaults = fs.existsSync(path.join(rootDir, ".cursor", "commands")) + ? [".cursor/commands"] + : []; + return mergeBundlePathLists(defaults, declared); +} + +function resolveCursorSkillDirs(raw: Record, rootDir: string): string[] { + return mergeBundlePathLists( + resolveCursorSkillsRootDirs(raw, rootDir), + resolveCursorCommandRootDirs(raw, rootDir), + ); +} + +function resolveCursorAgentDirs(raw: Record, rootDir: string): string[] { + const declared = normalizeBundlePathList(raw.subagents ?? raw.agents); + const defaults = fs.existsSync(path.join(rootDir, ".cursor", "agents")) ? [".cursor/agents"] : []; + return mergeBundlePathLists(defaults, declared); +} + +function hasCursorHookCapability(raw: Record, rootDir: string): boolean { + return ( + hasInlineCapabilityValue(raw.hooks) || + fs.existsSync(path.join(rootDir, ".cursor", "hooks.json")) + ); +} + +function hasCursorRulesCapability(raw: Record, rootDir: string): boolean { + return ( + hasInlineCapabilityValue(raw.rules) || fs.existsSync(path.join(rootDir, ".cursor", "rules")) + ); +} + +function hasCursorMcpCapability(raw: Record, rootDir: string): boolean { + return hasInlineCapabilityValue(raw.mcpServers) || fs.existsSync(path.join(rootDir, ".mcp.json")); +} + +function resolveClaudeComponentPaths( + raw: Record, + key: string, + rootDir: string, + defaults: string[], +): string[] { + const declared = normalizeBundlePathList(raw[key]); + const existingDefaults = defaults.filter((candidate) => + fs.existsSync(path.join(rootDir, candidate)), + ); + return mergeBundlePathLists(existingDefaults, declared); +} + +function resolveClaudeSkillsRootDirs(raw: Record, rootDir: string): string[] { + return resolveClaudeComponentPaths(raw, "skills", rootDir, ["skills"]); +} + +function resolveClaudeCommandRootDirs(raw: Record, rootDir: string): string[] { + return resolveClaudeComponentPaths(raw, "commands", rootDir, ["commands"]); +} + +function resolveClaudeSkillDirs(raw: Record, rootDir: string): string[] { + return mergeBundlePathLists( + resolveClaudeSkillsRootDirs(raw, rootDir), + resolveClaudeCommandRootDirs(raw, rootDir), + ); +} + +function resolveClaudeAgentDirs(raw: Record, rootDir: string): string[] { + return resolveClaudeComponentPaths(raw, "agents", rootDir, ["agents"]); +} + +function resolveClaudeHookPaths(raw: Record, rootDir: string): string[] { + return resolveClaudeComponentPaths(raw, "hooks", rootDir, ["hooks/hooks.json"]); +} + +function resolveClaudeMcpPaths(raw: Record, rootDir: string): string[] { + return resolveClaudeComponentPaths(raw, "mcpServers", rootDir, [".mcp.json"]); +} + +function resolveClaudeLspPaths(raw: Record, rootDir: string): string[] { + return resolveClaudeComponentPaths(raw, "lspServers", rootDir, [".lsp.json"]); +} + +function resolveClaudeOutputStylePaths(raw: Record, rootDir: string): string[] { + return resolveClaudeComponentPaths(raw, "outputStyles", rootDir, ["output-styles"]); +} + +function resolveClaudeSettingsFiles(_raw: Record, rootDir: string): string[] { + return fs.existsSync(path.join(rootDir, "settings.json")) ? ["settings.json"] : []; +} + +function hasClaudeHookCapability(raw: Record, rootDir: string): boolean { + return hasInlineCapabilityValue(raw.hooks) || resolveClaudeHookPaths(raw, rootDir).length > 0; +} + +function buildCodexCapabilities(raw: Record, rootDir: string): string[] { + const capabilities: string[] = []; + if (resolveCodexSkillDirs(raw, rootDir).length > 0) { + capabilities.push("skills"); + } + if (resolveCodexHookDirs(raw, rootDir).length > 0) { + capabilities.push("hooks"); + } + if (hasInlineCapabilityValue(raw.mcpServers) || fs.existsSync(path.join(rootDir, ".mcp.json"))) { + capabilities.push("mcpServers"); + } + if (hasInlineCapabilityValue(raw.apps) || fs.existsSync(path.join(rootDir, ".app.json"))) { + capabilities.push("apps"); + } + return capabilities; +} + +function buildClaudeCapabilities(raw: Record, rootDir: string): string[] { + const capabilities: string[] = []; + if (resolveClaudeSkillDirs(raw, rootDir).length > 0) { + capabilities.push("skills"); + } + if (resolveClaudeCommandRootDirs(raw, rootDir).length > 0) { + capabilities.push("commands"); + } + if (resolveClaudeAgentDirs(raw, rootDir).length > 0) { + capabilities.push("agents"); + } + if (hasClaudeHookCapability(raw, rootDir)) { + capabilities.push("hooks"); + } + if (hasInlineCapabilityValue(raw.mcpServers) || resolveClaudeMcpPaths(raw, rootDir).length > 0) { + capabilities.push("mcpServers"); + } + if (hasInlineCapabilityValue(raw.lspServers) || resolveClaudeLspPaths(raw, rootDir).length > 0) { + capabilities.push("lspServers"); + } + if ( + hasInlineCapabilityValue(raw.outputStyles) || + resolveClaudeOutputStylePaths(raw, rootDir).length > 0 + ) { + capabilities.push("outputStyles"); + } + if (resolveClaudeSettingsFiles(raw, rootDir).length > 0) { + capabilities.push("settings"); + } + return capabilities; +} + +function buildCursorCapabilities(raw: Record, rootDir: string): string[] { + const capabilities: string[] = []; + if (resolveCursorSkillDirs(raw, rootDir).length > 0) { + capabilities.push("skills"); + } + if (resolveCursorCommandRootDirs(raw, rootDir).length > 0) { + capabilities.push("commands"); + } + if (resolveCursorAgentDirs(raw, rootDir).length > 0) { + capabilities.push("agents"); + } + if (hasCursorHookCapability(raw, rootDir)) { + capabilities.push("hooks"); + } + if (hasCursorRulesCapability(raw, rootDir)) { + capabilities.push("rules"); + } + if (hasCursorMcpCapability(raw, rootDir)) { + capabilities.push("mcpServers"); + } + return capabilities; +} + +export function loadBundleManifest(params: { + rootDir: string; + bundleFormat: PluginBundleFormat; + rejectHardlinks?: boolean; +}): BundleManifestLoadResult { + const rejectHardlinks = params.rejectHardlinks ?? true; + const manifestRelativePath = + params.bundleFormat === "codex" + ? CODEX_BUNDLE_MANIFEST_RELATIVE_PATH + : params.bundleFormat === "cursor" + ? CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH + : CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH; + const loaded = loadBundleManifestFile({ + rootDir: params.rootDir, + manifestRelativePath, + rejectHardlinks, + allowMissing: params.bundleFormat === "claude", + }); + if (!loaded.ok) { + return loaded; + } + + const raw = loaded.raw; + const interfaceRecord = isRecord(raw.interface) ? raw.interface : undefined; + const name = normalizeString(raw.name); + const description = + normalizeString(raw.description) ?? + normalizeString(raw.shortDescription) ?? + normalizeString(interfaceRecord?.shortDescription); + const version = normalizeString(raw.version); + + if (params.bundleFormat === "codex") { + const skills = resolveCodexSkillDirs(raw, params.rootDir); + const hooks = resolveCodexHookDirs(raw, params.rootDir); + return { + ok: true, + manifest: { + id: slugifyPluginId(name, params.rootDir), + name, + description, + version, + skills, + settingsFiles: [], + hooks, + bundleFormat: "codex", + capabilities: buildCodexCapabilities(raw, params.rootDir), + }, + manifestPath: loaded.manifestPath, + }; + } + + if (params.bundleFormat === "cursor") { + return { + ok: true, + manifest: { + id: slugifyPluginId(name, params.rootDir), + name, + description, + version, + skills: resolveCursorSkillDirs(raw, params.rootDir), + settingsFiles: [], + hooks: [], + bundleFormat: "cursor", + capabilities: buildCursorCapabilities(raw, params.rootDir), + }, + manifestPath: loaded.manifestPath, + }; + } + + return { + ok: true, + manifest: { + id: slugifyPluginId(name, params.rootDir), + name, + description, + version, + skills: resolveClaudeSkillDirs(raw, params.rootDir), + settingsFiles: resolveClaudeSettingsFiles(raw, params.rootDir), + hooks: [], + bundleFormat: "claude", + capabilities: buildClaudeCapabilities(raw, params.rootDir), + }, + manifestPath: loaded.manifestPath, + }; +} + +export function detectBundleManifestFormat(rootDir: string): PluginBundleFormat | null { + if (fs.existsSync(path.join(rootDir, CODEX_BUNDLE_MANIFEST_RELATIVE_PATH))) { + return "codex"; + } + if (fs.existsSync(path.join(rootDir, CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH))) { + return "cursor"; + } + if (fs.existsSync(path.join(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH))) { + return "claude"; + } + if (fs.existsSync(path.join(rootDir, PLUGIN_MANIFEST_FILENAME))) { + return null; + } + if ( + DEFAULT_PLUGIN_ENTRY_CANDIDATES.some((candidate) => + fs.existsSync(path.join(rootDir, candidate)), + ) + ) { + return null; + } + const manifestlessClaudeMarkers = [ + path.join(rootDir, "skills"), + path.join(rootDir, "commands"), + path.join(rootDir, "agents"), + path.join(rootDir, "hooks", "hooks.json"), + path.join(rootDir, ".mcp.json"), + path.join(rootDir, ".lsp.json"), + path.join(rootDir, "settings.json"), + ]; + if (manifestlessClaudeMarkers.some((candidate) => fs.existsSync(candidate))) { + return "claude"; + } + return null; +} diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 1069c223b1e..a61c21e4125 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -219,6 +219,109 @@ describe("discoverOpenClawPlugins", () => { const ids = candidates.map((c) => c.idHint); expect(ids).toContain("demo-plugin-dir"); }); + + it("auto-detects Codex bundles as bundle candidates", async () => { + const stateDir = makeTempDir(); + const bundleDir = path.join(stateDir, "extensions", "sample-bundle"); + mkdirSafe(path.join(bundleDir, ".codex-plugin")); + mkdirSafe(path.join(bundleDir, "skills")); + fs.writeFileSync( + path.join(bundleDir, ".codex-plugin", "plugin.json"), + JSON.stringify({ + name: "Sample Bundle", + skills: "skills", + }), + "utf-8", + ); + + const { candidates } = await discoverWithStateDir(stateDir, {}); + const bundle = candidates.find((candidate) => candidate.idHint === "sample-bundle"); + + expect(bundle).toBeDefined(); + expect(bundle?.idHint).toBe("sample-bundle"); + expect(bundle?.format).toBe("bundle"); + expect(bundle?.bundleFormat).toBe("codex"); + expect(bundle?.source).toBe(bundleDir); + expect(bundle?.rootDir).toBe(fs.realpathSync.native(bundleDir)); + }); + + it("auto-detects manifestless Claude bundles from the default layout", async () => { + const stateDir = makeTempDir(); + const bundleDir = path.join(stateDir, "extensions", "claude-bundle"); + mkdirSafe(path.join(bundleDir, "commands")); + fs.writeFileSync(path.join(bundleDir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8"); + + const { candidates } = await discoverWithStateDir(stateDir, {}); + const bundle = candidates.find((candidate) => candidate.idHint === "claude-bundle"); + + expect(bundle).toBeDefined(); + expect(bundle?.format).toBe("bundle"); + expect(bundle?.bundleFormat).toBe("claude"); + expect(bundle?.source).toBe(bundleDir); + }); + + it("auto-detects Cursor bundles as bundle candidates", async () => { + const stateDir = makeTempDir(); + const bundleDir = path.join(stateDir, "extensions", "cursor-bundle"); + mkdirSafe(path.join(bundleDir, ".cursor-plugin")); + mkdirSafe(path.join(bundleDir, ".cursor", "commands")); + fs.writeFileSync( + path.join(bundleDir, ".cursor-plugin", "plugin.json"), + JSON.stringify({ + name: "Cursor Bundle", + }), + "utf-8", + ); + + const { candidates } = await discoverWithStateDir(stateDir, {}); + const bundle = candidates.find((candidate) => candidate.idHint === "cursor-bundle"); + + expect(bundle).toBeDefined(); + expect(bundle?.format).toBe("bundle"); + expect(bundle?.bundleFormat).toBe("cursor"); + expect(bundle?.source).toBe(bundleDir); + }); + + it("falls back to legacy index discovery when a scanned bundle sidecar is malformed", async () => { + const stateDir = makeTempDir(); + const pluginDir = path.join(stateDir, "extensions", "legacy-with-bad-bundle"); + mkdirSafe(path.join(pluginDir, ".claude-plugin")); + fs.writeFileSync(path.join(pluginDir, "index.ts"), "export default {}", "utf-8"); + fs.writeFileSync(path.join(pluginDir, ".claude-plugin", "plugin.json"), "{", "utf-8"); + + const result = await discoverWithStateDir(stateDir, {}); + const legacy = result.candidates.find( + (candidate) => candidate.idHint === "legacy-with-bad-bundle", + ); + + expect(legacy).toBeDefined(); + expect(legacy?.format).toBe("openclaw"); + expect( + result.diagnostics.some((entry) => entry.source?.endsWith(".claude-plugin/plugin.json")), + ).toBe(true); + }); + + it("falls back to legacy index discovery for configured paths with malformed bundle sidecars", async () => { + const stateDir = makeTempDir(); + const pluginDir = path.join(stateDir, "plugins", "legacy-with-bad-bundle"); + mkdirSafe(path.join(pluginDir, ".codex-plugin")); + fs.writeFileSync(path.join(pluginDir, "index.ts"), "export default {}", "utf-8"); + fs.writeFileSync(path.join(pluginDir, ".codex-plugin", "plugin.json"), "{", "utf-8"); + + const result = await discoverWithStateDir(stateDir, { + extraPaths: [pluginDir], + }); + const legacy = result.candidates.find( + (candidate) => candidate.idHint === "legacy-with-bad-bundle", + ); + + expect(legacy).toBeDefined(); + expect(legacy?.format).toBe("openclaw"); + expect( + result.diagnostics.some((entry) => entry.source?.endsWith(".codex-plugin/plugin.json")), + ).toBe(true); + }); + it("blocks extension entries that escape package directory", async () => { const stateDir = makeTempDir(); const globalExt = path.join(stateDir, "extensions", "escape-pack"); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 0ccf10831a9..c102ffc80c7 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { resolveUserPath } from "../utils.js"; +import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js"; import { DEFAULT_PLUGIN_ENTRY_CANDIDATES, getPackageManifestMetadata, @@ -11,7 +12,7 @@ import { } from "./manifest.js"; import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js"; import { resolvePluginCacheInputs, resolvePluginSourceRoots } from "./roots.js"; -import type { PluginDiagnostic, PluginOrigin } from "./types.js"; +import type { PluginBundleFormat, PluginDiagnostic, PluginFormat, PluginOrigin } from "./types.js"; const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]); @@ -20,6 +21,8 @@ export type PluginCandidate = { source: string; rootDir: string; origin: PluginOrigin; + format?: PluginFormat; + bundleFormat?: PluginBundleFormat; workspaceDir?: string; packageName?: string; packageVersion?: string; @@ -354,6 +357,8 @@ function addCandidate(params: { source: string; rootDir: string; origin: PluginOrigin; + format?: PluginFormat; + bundleFormat?: PluginBundleFormat; ownershipUid?: number | null; workspaceDir?: string; manifest?: PackageManifest | null; @@ -382,6 +387,8 @@ function addCandidate(params: { source: resolved, rootDir: resolvedRoot, origin: params.origin, + format: params.format ?? "openclaw", + bundleFormat: params.bundleFormat, workspaceDir: params.workspaceDir, packageName: manifest?.name?.trim() || undefined, packageVersion: manifest?.version?.trim() || undefined, @@ -391,6 +398,48 @@ function addCandidate(params: { }); } +function discoverBundleInRoot(params: { + rootDir: string; + origin: PluginOrigin; + ownershipUid?: number | null; + workspaceDir?: string; + candidates: PluginCandidate[]; + diagnostics: PluginDiagnostic[]; + seen: Set; +}): "added" | "invalid" | "none" { + const bundleFormat = detectBundleManifestFormat(params.rootDir); + if (!bundleFormat) { + return "none"; + } + const bundleManifest = loadBundleManifest({ + rootDir: params.rootDir, + bundleFormat, + rejectHardlinks: params.origin !== "bundled", + }); + if (!bundleManifest.ok) { + params.diagnostics.push({ + level: "error", + message: bundleManifest.error, + source: bundleManifest.manifestPath, + }); + return "invalid"; + } + addCandidate({ + candidates: params.candidates, + diagnostics: params.diagnostics, + seen: params.seen, + idHint: bundleManifest.manifest.id, + source: params.rootDir, + rootDir: params.rootDir, + origin: params.origin, + format: "bundle", + bundleFormat, + ownershipUid: params.ownershipUid, + workspaceDir: params.workspaceDir, + }); + return "added"; +} + function resolvePackageEntrySource(params: { packageDir: string; entryPath: string; @@ -505,6 +554,19 @@ function discoverInDirectory(params: { continue; } + const bundleDiscovery = discoverBundleInRoot({ + rootDir: fullPath, + origin: params.origin, + ownershipUid: params.ownershipUid, + workspaceDir: params.workspaceDir, + candidates: params.candidates, + diagnostics: params.diagnostics, + seen: params.seen, + }); + if (bundleDiscovery === "added") { + continue; + } + const indexFile = [...DEFAULT_PLUGIN_ENTRY_CANDIDATES] .map((candidate) => path.join(fullPath, candidate)) .find((candidate) => fs.existsSync(candidate)); @@ -609,6 +671,19 @@ function discoverFromPath(params: { return; } + const bundleDiscovery = discoverBundleInRoot({ + rootDir: resolved, + origin: params.origin, + ownershipUid: params.ownershipUid, + workspaceDir: params.workspaceDir, + candidates: params.candidates, + diagnostics: params.diagnostics, + seen: params.seen, + }); + if (bundleDiscovery === "added") { + return; + } + const indexFile = [...DEFAULT_PLUGIN_ENTRY_CANDIDATES] .map((candidate) => path.join(resolved, candidate)) .find((candidate) => fs.existsSync(candidate)); diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index db2fcfaf8f9..c6c09042c84 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -5,7 +5,10 @@ import * as tar from "tar"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { safePathSegmentHashed } from "../infra/install-safe-path.js"; import * as skillScanner from "../security/skill-scanner.js"; -import { expectSingleNpmPackIgnoreScriptsCall } from "../test-utils/exec-assertions.js"; +import { + expectSingleNpmInstallIgnoreScriptsCall, + expectSingleNpmPackIgnoreScriptsCall, +} from "../test-utils/exec-assertions.js"; import { expectInstallUsesIgnoreScripts, expectIntegrityDriftRejected, @@ -235,6 +238,107 @@ function setupManifestInstallFixture(params: { manifestId: string }) { return { pluginDir, extensionsDir: path.join(stateDir, "extensions") }; } +function setupBundleInstallFixture(params: { + bundleFormat: "codex" | "claude" | "cursor"; + name: string; +}) { + const caseDir = makeTempDir(); + const stateDir = path.join(caseDir, "state"); + const pluginDir = path.join(caseDir, "plugin-src"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.mkdirSync(path.join(pluginDir, "skills"), { recursive: true }); + const manifestDir = path.join( + pluginDir, + params.bundleFormat === "codex" + ? ".codex-plugin" + : params.bundleFormat === "cursor" + ? ".cursor-plugin" + : ".claude-plugin", + ); + fs.mkdirSync(manifestDir, { recursive: true }); + fs.writeFileSync( + path.join(manifestDir, "plugin.json"), + JSON.stringify({ + name: params.name, + description: `${params.bundleFormat} bundle fixture`, + ...(params.bundleFormat === "codex" ? { skills: "skills" } : {}), + }), + "utf-8", + ); + if (params.bundleFormat === "cursor") { + fs.mkdirSync(path.join(pluginDir, ".cursor", "commands"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, ".cursor", "commands", "review.md"), + "---\ndescription: fixture\n---\n", + "utf-8", + ); + } + fs.writeFileSync( + path.join(pluginDir, "skills", "SKILL.md"), + "---\ndescription: fixture\n---\n", + "utf-8", + ); + return { pluginDir, extensionsDir: path.join(stateDir, "extensions") }; +} + +function setupManifestlessClaudeInstallFixture() { + const caseDir = makeTempDir(); + const stateDir = path.join(caseDir, "state"); + const pluginDir = path.join(caseDir, "claude-manifestless"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.mkdirSync(path.join(pluginDir, "commands"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "commands", "review.md"), + "---\ndescription: fixture\n---\n", + "utf-8", + ); + fs.writeFileSync(path.join(pluginDir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8"); + return { pluginDir, extensionsDir: path.join(stateDir, "extensions") }; +} + +function setupDualFormatInstallFixture(params: { bundleFormat: "codex" | "claude" }) { + const caseDir = makeTempDir(); + const stateDir = path.join(caseDir, "state"); + const pluginDir = path.join(caseDir, "plugin-src"); + fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true }); + fs.mkdirSync(path.join(pluginDir, "skills"), { recursive: true }); + const manifestDir = path.join( + pluginDir, + params.bundleFormat === "codex" ? ".codex-plugin" : ".claude-plugin", + ); + fs.mkdirSync(manifestDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "@openclaw/native-dual", + version: "0.0.1", + openclaw: { extensions: ["./dist/index.js"] }, + dependencies: { "left-pad": "1.3.0" }, + }), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "native-dual", + configSchema: { type: "object", properties: {} }, + skills: ["skills"], + }), + "utf-8", + ); + fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};", "utf-8"); + fs.writeFileSync(path.join(pluginDir, "skills", "SKILL.md"), "---\ndescription: fixture\n---\n"); + fs.writeFileSync( + path.join(manifestDir, "plugin.json"), + JSON.stringify({ + name: "Bundle Fallback", + ...(params.bundleFormat === "codex" ? { skills: "skills" } : {}), + }), + "utf-8", + ); + return { pluginDir, extensionsDir: path.join(stateDir, "extensions") }; +} + async function expectArchiveInstallReservedSegmentRejection(params: { packageName: string; outName: string; @@ -770,6 +874,95 @@ describe("installPluginFromDir", () => { expect(path.basename(scopedTarget)).toBe(`@${hashedFlatId}`); expect(scopedTarget).not.toBe(flatTarget); }); + + it("installs Codex bundles from a local directory", async () => { + const { pluginDir, extensionsDir } = setupBundleInstallFixture({ + bundleFormat: "codex", + name: "Sample Bundle", + }); + + const res = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.pluginId).toBe("sample-bundle"); + expect(fs.existsSync(path.join(res.targetDir, ".codex-plugin", "plugin.json"))).toBe(true); + expect(fs.existsSync(path.join(res.targetDir, "skills", "SKILL.md"))).toBe(true); + }); + + it("prefers native package installs over bundle installs for dual-format directories", async () => { + const { pluginDir, extensionsDir } = setupDualFormatInstallFixture({ + bundleFormat: "codex", + }); + + const run = vi.mocked(runCommandWithTimeout); + run.mockResolvedValue({ + code: 0, + stdout: "", + stderr: "", + signal: null, + killed: false, + termination: "exit", + }); + + const res = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.pluginId).toBe("native-dual"); + expect(res.targetDir).toBe(path.join(extensionsDir, "native-dual")); + expectSingleNpmInstallIgnoreScriptsCall({ + calls: run.mock.calls as Array<[unknown, { cwd?: string } | undefined]>, + expectedTargetDir: res.targetDir, + }); + }); + + it("installs manifestless Claude bundles from a local directory", async () => { + const { pluginDir, extensionsDir } = setupManifestlessClaudeInstallFixture(); + + const res = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.pluginId).toBe("claude-manifestless"); + expect(fs.existsSync(path.join(res.targetDir, "commands", "review.md"))).toBe(true); + expect(fs.existsSync(path.join(res.targetDir, "settings.json"))).toBe(true); + }); + + it("installs Cursor bundles from a local directory", async () => { + const { pluginDir, extensionsDir } = setupBundleInstallFixture({ + bundleFormat: "cursor", + name: "Cursor Sample", + }); + + const res = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.pluginId).toBe("cursor-sample"); + expect(fs.existsSync(path.join(res.targetDir, ".cursor-plugin", "plugin.json"))).toBe(true); + expect(fs.existsSync(path.join(res.targetDir, ".cursor", "commands", "review.md"))).toBe(true); + }); }); describe("installPluginFromPath", () => { @@ -801,6 +994,69 @@ describe("installPluginFromPath", () => { expect(result.error.toLowerCase()).toMatch(/hardlink|path alias escape/); expect(fs.readFileSync(victimPath, "utf-8")).toBe("ORIGINAL"); }); + + it("installs Claude bundles from an archive path", async () => { + const { pluginDir, extensionsDir } = setupBundleInstallFixture({ + bundleFormat: "claude", + name: "Claude Sample", + }); + const archivePath = path.join(makeTempDir(), "claude-bundle.tgz"); + + await packToArchive({ + pkgDir: pluginDir, + outDir: path.dirname(archivePath), + outName: path.basename(archivePath), + }); + + const result = await installPluginFromPath({ + path: archivePath, + extensionsDir, + }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.pluginId).toBe("claude-sample"); + expect(fs.existsSync(path.join(result.targetDir, ".claude-plugin", "plugin.json"))).toBe(true); + }); + + it("prefers native package installs over bundle installs for dual-format archives", async () => { + const { pluginDir, extensionsDir } = setupDualFormatInstallFixture({ + bundleFormat: "claude", + }); + const archivePath = path.join(makeTempDir(), "dual-format.tgz"); + + await packToArchive({ + pkgDir: pluginDir, + outDir: path.dirname(archivePath), + outName: path.basename(archivePath), + }); + + const run = vi.mocked(runCommandWithTimeout); + run.mockResolvedValue({ + code: 0, + stdout: "", + stderr: "", + signal: null, + killed: false, + termination: "exit", + }); + + const result = await installPluginFromPath({ + path: archivePath, + extensionsDir, + }); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.pluginId).toBe("native-dual"); + expect(result.targetDir).toBe(path.join(extensionsDir, "native-dual")); + expectSingleNpmInstallIgnoreScriptsCall({ + calls: run.mock.calls as Array<[unknown, { cwd?: string } | undefined]>, + expectedTargetDir: result.targetDir, + }); + }); }); describe("installPluginFromNpmSpec", () => { diff --git a/src/plugins/install.ts b/src/plugins/install.ts index ab87377d32e..e6b66381970 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -31,6 +31,7 @@ import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js"; import * as skillScanner from "../security/skill-scanner.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; +import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js"; import { loadPluginManifest, resolvePackageExtensionEntries, @@ -253,6 +254,156 @@ export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string return targetDirResult.path; } +async function installBundleFromSourceDir( + params: { + sourceDir: string; + } & PackageInstallCommonParams, +): Promise { + const bundleFormat = detectBundleManifestFormat(params.sourceDir); + if (!bundleFormat) { + return null; + } + + const { logger, timeoutMs, mode, dryRun } = resolveTimedInstallModeOptions(params, defaultLogger); + const manifestRes = loadBundleManifest({ + rootDir: params.sourceDir, + bundleFormat, + rejectHardlinks: true, + }); + if (!manifestRes.ok) { + return { ok: false, error: manifestRes.error }; + } + + const pluginId = manifestRes.manifest.id; + const pluginIdError = validatePluginId(pluginId); + if (pluginIdError) { + return { ok: false, error: pluginIdError }; + } + if (params.expectedPluginId && params.expectedPluginId !== pluginId) { + return { + ok: false, + error: `plugin id mismatch: expected ${params.expectedPluginId}, got ${pluginId}`, + code: PLUGIN_INSTALL_ERROR_CODE.PLUGIN_ID_MISMATCH, + }; + } + + try { + const scanSummary = await skillScanner.scanDirectoryWithSummary(params.sourceDir); + if (scanSummary.critical > 0) { + const criticalDetails = scanSummary.findings + .filter((f) => f.severity === "critical") + .map((f) => `${f.message} (${f.file}:${f.line})`) + .join("; "); + logger.warn?.( + `WARNING: Bundle "${pluginId}" contains dangerous code patterns: ${criticalDetails}`, + ); + } else if (scanSummary.warn > 0) { + logger.warn?.( + `Bundle "${pluginId}" has ${scanSummary.warn} suspicious code pattern(s). Run "openclaw security audit --deep" for details.`, + ); + } + } catch (err) { + logger.warn?.( + `Bundle "${pluginId}" code safety scan failed (${String(err)}). Installation continues; run "openclaw security audit --deep" after install.`, + ); + } + + 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({ + sourceDir: params.sourceDir, + targetDir, + mode, + timeoutMs, + logger, + 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( + params: { + sourceDir: string; + } & PackageInstallCommonParams, +): Promise { + const nativePackageDetected = await detectNativePackageInstallSource(params.sourceDir); + if (nativePackageDetected) { + return await installPluginFromPackageDir({ + packageDir: params.sourceDir, + ...pickPackageInstallCommonParams(params), + }); + } + const bundleResult = await installBundleFromSourceDir({ + sourceDir: params.sourceDir, + ...pickPackageInstallCommonParams(params), + }); + if (bundleResult) { + return bundleResult; + } + return await installPluginFromPackageDir({ + packageDir: params.sourceDir, + ...pickPackageInstallCommonParams(params), + }); +} + +async function detectNativePackageInstallSource(packageDir: string): Promise { + const manifestPath = path.join(packageDir, "package.json"); + if (!(await fileExists(manifestPath))) { + return false; + } + + try { + const manifest = await readJsonFile(manifestPath); + return ensureOpenClawExtensions({ manifest }).ok; + } catch { + return false; + } +} + async function installPluginFromPackageDir( params: { packageDir: string; @@ -454,9 +605,9 @@ export async function installPluginFromArchive( tempDirPrefix: "openclaw-plugin-", timeoutMs, logger, - onExtracted: async (packageDir) => - await installPluginFromPackageDir({ - packageDir, + onExtracted: async (sourceDir) => + await installPluginFromSourceDir({ + sourceDir, ...pickPackageInstallCommonParams({ extensionsDir: params.extensionsDir, timeoutMs, @@ -483,8 +634,8 @@ export async function installPluginFromDir( return { ok: false, error: `not a directory: ${dirPath}` }; } - return await installPluginFromPackageDir({ - packageDir: dirPath, + return await installPluginFromSourceDir({ + sourceDir: dirPath, ...pickPackageInstallCommonParams(params), }); } diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 939e9a9f56c..eec2cf4f410 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -309,6 +309,131 @@ afterEach(() => { } }); +describe("bundle plugins", () => { + it("reports Codex bundles as loaded bundle plugins without importing runtime code", () => { + const workspaceDir = makeTempDir(); + const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "sample-bundle"); + mkdirSafe(path.join(bundleRoot, ".codex-plugin")); + mkdirSafe(path.join(bundleRoot, "skills")); + fs.writeFileSync( + path.join(bundleRoot, ".codex-plugin", "plugin.json"), + JSON.stringify({ + name: "Sample Bundle", + description: "Codex bundle fixture", + skills: "skills", + }), + "utf-8", + ); + fs.writeFileSync( + path.join(bundleRoot, "skills", "SKILL.md"), + "---\ndescription: fixture\n---\n", + ); + + const registry = loadOpenClawPlugins({ + workspaceDir, + config: { + plugins: { + entries: { + "sample-bundle": { + enabled: true, + }, + }, + }, + }, + cache: false, + }); + + const plugin = registry.plugins.find((entry) => entry.id === "sample-bundle"); + expect(plugin?.status).toBe("loaded"); + expect(plugin?.format).toBe("bundle"); + expect(plugin?.bundleFormat).toBe("codex"); + expect(plugin?.bundleCapabilities).toContain("skills"); + }); + + it("treats Claude command roots and settings as supported bundle surfaces", () => { + const workspaceDir = makeTempDir(); + const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "claude-skills"); + mkdirSafe(path.join(bundleRoot, "commands")); + fs.writeFileSync( + path.join(bundleRoot, "commands", "review.md"), + "---\ndescription: fixture\n---\n", + ); + fs.writeFileSync(path.join(bundleRoot, "settings.json"), '{"hideThinkingBlock":true}', "utf-8"); + + const registry = loadOpenClawPlugins({ + workspaceDir, + config: { + plugins: { + entries: { + "claude-skills": { + enabled: true, + }, + }, + }, + }, + cache: false, + }); + + const plugin = registry.plugins.find((entry) => entry.id === "claude-skills"); + expect(plugin?.status).toBe("loaded"); + expect(plugin?.bundleFormat).toBe("claude"); + expect(plugin?.bundleCapabilities).toEqual( + expect.arrayContaining(["skills", "commands", "settings"]), + ); + expect( + registry.diagnostics.some( + (diag) => + diag.pluginId === "claude-skills" && + diag.message.includes("bundle capability detected but not wired"), + ), + ).toBe(false); + }); + + it("treats Cursor command roots as supported bundle skill surfaces", () => { + const workspaceDir = makeTempDir(); + const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "cursor-skills"); + mkdirSafe(path.join(bundleRoot, ".cursor-plugin")); + mkdirSafe(path.join(bundleRoot, ".cursor", "commands")); + fs.writeFileSync( + path.join(bundleRoot, ".cursor-plugin", "plugin.json"), + JSON.stringify({ + name: "Cursor Skills", + }), + "utf-8", + ); + fs.writeFileSync( + path.join(bundleRoot, ".cursor", "commands", "review.md"), + "---\ndescription: fixture\n---\n", + ); + + const registry = loadOpenClawPlugins({ + workspaceDir, + config: { + plugins: { + entries: { + "cursor-skills": { + enabled: true, + }, + }, + }, + }, + cache: false, + }); + + const plugin = registry.plugins.find((entry) => entry.id === "cursor-skills"); + expect(plugin?.status).toBe("loaded"); + expect(plugin?.bundleFormat).toBe("cursor"); + expect(plugin?.bundleCapabilities).toEqual(expect.arrayContaining(["skills", "commands"])); + expect( + registry.diagnostics.some( + (diag) => + diag.pluginId === "cursor-skills" && + diag.message.includes("bundle capability detected but not wired"), + ), + ).toBe(false); + }); +}); + afterAll(() => { try { fs.rmSync(fixtureRoot, { recursive: true, force: true }); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 1549835d60a..319b0ae90d7 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -32,6 +32,8 @@ import type { OpenClawPluginDefinition, OpenClawPluginModule, PluginDiagnostic, + PluginBundleFormat, + PluginFormat, PluginLogger, } from "./types.js"; @@ -317,6 +319,9 @@ function createPluginRecord(params: { name?: string; description?: string; version?: string; + format?: PluginFormat; + bundleFormat?: PluginBundleFormat; + bundleCapabilities?: string[]; source: string; rootDir?: string; origin: PluginRecord["origin"]; @@ -329,6 +334,9 @@ function createPluginRecord(params: { name: params.name ?? params.id, description: params.description, version: params.version, + format: params.format ?? "openclaw", + bundleFormat: params.bundleFormat, + bundleCapabilities: params.bundleCapabilities, source: params.source, rootDir: params.rootDir, origin: params.origin, @@ -785,6 +793,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi name: manifestRecord.name ?? pluginId, description: manifestRecord.description, version: manifestRecord.version, + format: manifestRecord.format, + bundleFormat: manifestRecord.bundleFormat, + bundleCapabilities: manifestRecord.bundleCapabilities, source: candidate.source, rootDir: candidate.rootDir, origin: candidate.origin, @@ -810,6 +821,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi name: manifestRecord.name ?? pluginId, description: manifestRecord.description, version: manifestRecord.version, + format: manifestRecord.format, + bundleFormat: manifestRecord.bundleFormat, + bundleCapabilities: manifestRecord.bundleCapabilities, source: candidate.source, rootDir: candidate.rootDir, origin: candidate.origin, @@ -841,6 +855,30 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } + if (record.format === "bundle") { + const unsupportedCapabilities = (record.bundleCapabilities ?? []).filter( + (capability) => + capability !== "skills" && + capability !== "settings" && + !( + capability === "commands" && + (record.bundleFormat === "claude" || record.bundleFormat === "cursor") + ) && + !(capability === "hooks" && record.bundleFormat === "codex"), + ); + for (const capability of unsupportedCapabilities) { + registry.diagnostics.push({ + level: "warn", + pluginId: record.id, + source: record.source, + message: `bundle capability detected but not wired into OpenClaw yet: ${capability}`, + }); + } + registry.plugins.push(record); + seenIds.set(pluginId, candidate.origin); + continue; + } + // Fast-path bundled memory plugins that are guaranteed disabled by slot policy. // This avoids opening/importing heavy memory plugin modules that will never register. if (candidate.origin === "bundled" && manifestRecord.kind === "memory") { diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 214c9b3b23f..a05576bc96d 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -35,12 +35,16 @@ function createPluginCandidate(params: { rootDir: string; sourceName?: string; origin: "bundled" | "global" | "workspace" | "config"; + format?: "openclaw" | "bundle"; + bundleFormat?: "codex" | "claude" | "cursor"; }): PluginCandidate { return { idHint: params.idHint, source: path.join(params.rootDir, params.sourceName ?? "index.ts"), rootDir: params.rootDir, origin: params.origin, + format: params.format, + bundleFormat: params.bundleFormat, }; } @@ -310,6 +314,148 @@ describe("loadPluginManifestRegistry", () => { expect(countDuplicateWarnings(loadRegistry(candidates))).toBe(0); }); + it("loads Codex bundle manifests into the registry", () => { + const bundleDir = makeTempDir(); + mkdirSafe(path.join(bundleDir, ".codex-plugin")); + mkdirSafe(path.join(bundleDir, "skills")); + fs.writeFileSync( + path.join(bundleDir, ".codex-plugin", "plugin.json"), + JSON.stringify({ + name: "Sample Bundle", + description: "Bundle fixture", + skills: "skills", + hooks: "hooks", + }), + "utf-8", + ); + mkdirSafe(path.join(bundleDir, "hooks")); + + const registry = loadRegistry([ + createPluginCandidate({ + idHint: "sample-bundle", + rootDir: bundleDir, + origin: "global", + format: "bundle", + bundleFormat: "codex", + }), + ]); + + expect(registry.plugins).toHaveLength(1); + expect(registry.plugins[0]).toMatchObject({ + id: "sample-bundle", + format: "bundle", + bundleFormat: "codex", + hooks: ["hooks"], + skills: ["skills"], + bundleCapabilities: expect.arrayContaining(["hooks", "skills"]), + }); + }); + + it("loads Claude bundle manifests with command roots and settings files", () => { + const bundleDir = makeTempDir(); + mkdirSafe(path.join(bundleDir, ".claude-plugin")); + mkdirSafe(path.join(bundleDir, "skill-packs", "starter")); + mkdirSafe(path.join(bundleDir, "commands-pack")); + fs.writeFileSync(path.join(bundleDir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8"); + fs.writeFileSync( + path.join(bundleDir, ".claude-plugin", "plugin.json"), + JSON.stringify({ + name: "Claude Sample", + skills: ["skill-packs/starter"], + commands: "commands-pack", + }), + "utf-8", + ); + + const registry = loadRegistry([ + createPluginCandidate({ + idHint: "claude-sample", + rootDir: bundleDir, + origin: "global", + format: "bundle", + bundleFormat: "claude", + }), + ]); + + expect(registry.plugins).toHaveLength(1); + expect(registry.plugins[0]).toMatchObject({ + id: "claude-sample", + format: "bundle", + bundleFormat: "claude", + skills: ["skill-packs/starter", "commands-pack"], + settingsFiles: ["settings.json"], + bundleCapabilities: expect.arrayContaining(["skills", "commands", "settings"]), + }); + }); + + it("loads manifestless Claude bundles into the registry", () => { + const bundleDir = makeTempDir(); + mkdirSafe(path.join(bundleDir, "commands")); + fs.writeFileSync(path.join(bundleDir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8"); + + const registry = loadRegistry([ + createPluginCandidate({ + idHint: "manifestless-claude", + rootDir: bundleDir, + origin: "global", + format: "bundle", + bundleFormat: "claude", + }), + ]); + + expect(registry.plugins).toHaveLength(1); + expect(registry.plugins[0]).toMatchObject({ + format: "bundle", + bundleFormat: "claude", + skills: ["commands"], + settingsFiles: ["settings.json"], + bundleCapabilities: expect.arrayContaining(["skills", "commands", "settings"]), + }); + }); + + it("loads Cursor bundle manifests into the registry", () => { + const bundleDir = makeTempDir(); + mkdirSafe(path.join(bundleDir, ".cursor-plugin")); + mkdirSafe(path.join(bundleDir, "skills")); + mkdirSafe(path.join(bundleDir, ".cursor", "commands")); + mkdirSafe(path.join(bundleDir, ".cursor", "rules")); + fs.writeFileSync(path.join(bundleDir, ".cursor", "hooks.json"), '{"hooks":[]}', "utf-8"); + fs.writeFileSync( + path.join(bundleDir, ".cursor-plugin", "plugin.json"), + JSON.stringify({ + name: "Cursor Sample", + mcpServers: "./.mcp.json", + }), + "utf-8", + ); + fs.writeFileSync(path.join(bundleDir, ".mcp.json"), '{"servers":{}}', "utf-8"); + + const registry = loadRegistry([ + createPluginCandidate({ + idHint: "cursor-sample", + rootDir: bundleDir, + origin: "global", + format: "bundle", + bundleFormat: "cursor", + }), + ]); + + expect(registry.plugins).toHaveLength(1); + expect(registry.plugins[0]).toMatchObject({ + id: "cursor-sample", + format: "bundle", + bundleFormat: "cursor", + skills: ["skills", ".cursor/commands"], + bundleCapabilities: expect.arrayContaining([ + "skills", + "commands", + "rules", + "hooks", + "mcpServers", + ]), + }); + }); + it("prefers higher-precedence origins for the same physical directory (config > workspace > global > bundled)", () => { const dir = makeTempDir(); mkdirSafe(path.join(dir, "sub")); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 285b3042004..b0f98b3beef 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -1,12 +1,20 @@ import fs from "node:fs"; import type { OpenClawConfig } from "../config/config.js"; import { resolveUserPath } from "../utils.js"; +import { loadBundleManifest } from "./bundle-manifest.js"; import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js"; import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; import { loadPluginManifest, type PluginManifest } from "./manifest.js"; import { isPathInside, safeRealpathSync } from "./path-safety.js"; import { resolvePluginCacheInputs } from "./roots.js"; -import type { PluginConfigUiHint, PluginDiagnostic, PluginKind, PluginOrigin } from "./types.js"; +import type { + PluginBundleFormat, + PluginConfigUiHint, + PluginDiagnostic, + PluginFormat, + PluginKind, + PluginOrigin, +} from "./types.js"; type SeenIdEntry = { candidate: PluginCandidate; @@ -27,10 +35,15 @@ export type PluginManifestRecord = { name?: string; description?: string; version?: string; + format?: PluginFormat; + bundleFormat?: PluginBundleFormat; + bundleCapabilities?: string[]; kind?: PluginKind; channels: string[]; providers: string[]; skills: string[]; + settingsFiles?: string[]; + hooks: string[]; origin: PluginOrigin; workspaceDir?: string; rootDir: string; @@ -122,10 +135,14 @@ function buildRecord(params: { description: normalizeManifestLabel(params.manifest.description) ?? params.candidate.packageDescription, version: normalizeManifestLabel(params.manifest.version) ?? params.candidate.packageVersion, + format: params.candidate.format ?? "openclaw", + bundleFormat: params.candidate.bundleFormat, kind: params.manifest.kind, channels: params.manifest.channels ?? [], providers: params.manifest.providers ?? [], skills: params.manifest.skills ?? [], + settingsFiles: [], + hooks: [], origin: params.candidate.origin, workspaceDir: params.candidate.workspaceDir, rootDir: params.candidate.rootDir, @@ -137,6 +154,44 @@ function buildRecord(params: { }; } +function buildBundleRecord(params: { + manifest: { + id: string; + name?: string; + description?: string; + version?: string; + skills: string[]; + settingsFiles?: string[]; + hooks: string[]; + capabilities: string[]; + }; + candidate: PluginCandidate; + manifestPath: string; +}): PluginManifestRecord { + return { + id: params.manifest.id, + name: normalizeManifestLabel(params.manifest.name) ?? params.candidate.idHint, + description: normalizeManifestLabel(params.manifest.description), + version: normalizeManifestLabel(params.manifest.version), + format: "bundle", + bundleFormat: params.candidate.bundleFormat, + bundleCapabilities: params.manifest.capabilities, + channels: [], + providers: [], + skills: params.manifest.skills ?? [], + settingsFiles: params.manifest.settingsFiles ?? [], + hooks: params.manifest.hooks ?? [], + origin: params.candidate.origin, + workspaceDir: params.candidate.workspaceDir, + rootDir: params.candidate.rootDir, + source: params.candidate.source, + manifestPath: params.manifestPath, + schemaCacheKey: undefined, + configSchema: undefined, + configUiHints: undefined, + }; +} + function matchesInstalledPluginRecord(params: { pluginId: string; candidate: PluginCandidate; @@ -230,7 +285,15 @@ export function loadPluginManifestRegistry(params: { for (const candidate of candidates) { const rejectHardlinks = candidate.origin !== "bundled"; - const manifestRes = loadPluginManifest(candidate.rootDir, rejectHardlinks); + const isBundleRecord = (candidate.format ?? "openclaw") === "bundle"; + const manifestRes = + isBundleRecord && candidate.bundleFormat + ? loadBundleManifest({ + rootDir: candidate.rootDir, + bundleFormat: candidate.bundleFormat, + rejectHardlinks, + }) + : loadPluginManifest(candidate.rootDir, rejectHardlinks); if (!manifestRes.ok) { diagnostics.push({ level: "error", @@ -250,7 +313,7 @@ export function loadPluginManifestRegistry(params: { }); } - const configSchema = manifest.configSchema; + const configSchema = "configSchema" in manifest ? manifest.configSchema : undefined; const schemaCacheKey = (() => { if (!configSchema) { return undefined; @@ -279,13 +342,19 @@ export function loadPluginManifestRegistry(params: { // Prefer higher-precedence origins even if candidates are passed in // an unexpected order (config > workspace > global > bundled). if (PLUGIN_ORIGIN_RANK[candidate.origin] < PLUGIN_ORIGIN_RANK[existing.candidate.origin]) { - records[existing.recordIndex] = buildRecord({ - manifest, - candidate, - manifestPath: manifestRes.manifestPath, - schemaCacheKey, - configSchema, - }); + records[existing.recordIndex] = isBundleRecord + ? buildBundleRecord({ + manifest: manifest as Parameters[0]["manifest"], + candidate, + manifestPath: manifestRes.manifestPath, + }) + : buildRecord({ + manifest: manifest as PluginManifest, + candidate, + manifestPath: manifestRes.manifestPath, + schemaCacheKey, + configSchema, + }); seenIds.set(manifest.id, { candidate, recordIndex: existing.recordIndex }); } continue; @@ -315,13 +384,19 @@ export function loadPluginManifestRegistry(params: { } records.push( - buildRecord({ - manifest, - candidate, - manifestPath: manifestRes.manifestPath, - schemaCacheKey, - configSchema, - }), + isBundleRecord + ? buildBundleRecord({ + manifest: manifest as Parameters[0]["manifest"], + candidate, + manifestPath: manifestRes.manifestPath, + }) + : buildRecord({ + manifest: manifest as PluginManifest, + candidate, + manifestPath: manifestRes.manifestPath, + schemaCacheKey, + configSchema, + }), ); } diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 8d1e5f92eb0..d754d928f15 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -38,6 +38,8 @@ import type { OpenClawPluginToolFactory, PluginConfigUiHint, PluginDiagnostic, + PluginBundleFormat, + PluginFormat, PluginLogger, PluginOrigin, PluginKind, @@ -120,6 +122,9 @@ export type PluginRecord = { name: string; version?: string; description?: string; + format?: PluginFormat; + bundleFormat?: PluginBundleFormat; + bundleCapabilities?: string[]; kind?: PluginKind; source: string; rootDir?: string; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 19542b44c2d..4cb6ef92ee4 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -800,6 +800,10 @@ export type OpenClawPluginApi = { export type PluginOrigin = "bundled" | "global" | "workspace" | "config"; +export type PluginFormat = "openclaw" | "bundle"; + +export type PluginBundleFormat = "codex" | "claude" | "cursor"; + export type PluginDiagnostic = { level: "warn" | "error"; message: string; From 4adcfa3256bfd4319e9593c01608aa845b549761 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 16:09:15 -0700 Subject: [PATCH 036/943] feat(plugins): move provider runtimes into bundled plugins --- docs/concepts/model-providers.md | 32 ++- docs/tools/plugin.md | 68 ++++- extensions/minimax-portal-auth/index.ts | 55 +--- extensions/qwen-portal-auth/index.ts | 13 +- .../models-config.providers.moonshot.test.ts | 12 +- src/agents/models-config.providers.ts | 245 ++---------------- ...ra-params.openrouter-cache-control.test.ts | 12 +- src/agents/pi-embedded-runner/extra-params.ts | 60 ++--- src/agents/provider-capabilities.test.ts | 9 + src/agents/provider-capabilities.ts | 9 - src/plugins/config-state.ts | 18 ++ 11 files changed, 193 insertions(+), 340 deletions(-) diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 8793e3fe1d6..a56b8f76284 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -52,6 +52,13 @@ Current bundled examples: hints, and runtime token exchange - `openai-codex`: forward-compat model fallback, transport normalization, and default transport params +- `moonshot`: shared transport, plugin-owned thinking payload normalization +- `kilocode`: shared transport, plugin-owned request headers, reasoning payload + normalization, Gemini transcript hints, and cache-TTL policy +- `byteplus`, `cloudflare-ai-gateway`, `huggingface`, `kimi-coding`, + `minimax`, `minimax-portal`, `modelstudio`, `nvidia`, `qianfan`, + `qwen-portal`, `synthetic`, `together`, `venice`, `vercel-ai-gateway`, + `volcengine`, and `xiaomi`: plugin-owned catalogs only That covers providers that still fit OpenClaw's normal transports. A provider that needs a totally custom request executor is a separate, deeper extension @@ -194,12 +201,26 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** See [/providers/kilocode](/providers/kilocode) for setup details. -### Other built-in providers +### Other bundled provider plugins - OpenRouter: `openrouter` (`OPENROUTER_API_KEY`) - Example model: `openrouter/anthropic/claude-sonnet-4-5` - Kilo Gateway: `kilocode` (`KILOCODE_API_KEY`) - Example model: `kilocode/anthropic/claude-opus-4.6` +- MiniMax: `minimax` (`MINIMAX_API_KEY`) +- Moonshot: `moonshot` (`MOONSHOT_API_KEY`) +- Kimi Coding: `kimi-coding` (`KIMI_API_KEY` or `KIMICODE_API_KEY`) +- Qianfan: `qianfan` (`QIANFAN_API_KEY`) +- Model Studio: `modelstudio` (`MODELSTUDIO_API_KEY`) +- NVIDIA: `nvidia` (`NVIDIA_API_KEY`) +- Together: `together` (`TOGETHER_API_KEY`) +- Venice: `venice` (`VENICE_API_KEY`) +- Xiaomi: `xiaomi` (`XIAOMI_API_KEY`) +- Vercel AI Gateway: `vercel-ai-gateway` (`AI_GATEWAY_API_KEY`) +- Hugging Face Inference: `huggingface` (`HUGGINGFACE_HUB_TOKEN` or `HF_TOKEN`) +- Cloudflare AI Gateway: `cloudflare-ai-gateway` (`CLOUDFLARE_AI_GATEWAY_API_KEY`) +- Volcengine: `volcengine` (`VOLCANO_ENGINE_API_KEY`) +- BytePlus: `byteplus` (`BYTEPLUS_API_KEY`) - xAI: `xai` (`XAI_API_KEY`) - Mistral: `mistral` (`MISTRAL_API_KEY`) - Example model: `mistral/mistral-large-latest` @@ -209,13 +230,17 @@ See [/providers/kilocode](/providers/kilocode) for setup details. - GLM models on Cerebras use ids `zai-glm-4.7` and `zai-glm-4.6`. - OpenAI-compatible base URL: `https://api.cerebras.ai/v1`. - GitHub Copilot: `github-copilot` (`COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN`) -- Hugging Face Inference: `huggingface` (`HUGGINGFACE_HUB_TOKEN` or `HF_TOKEN`) — OpenAI-compatible router; example model: `huggingface/deepseek-ai/DeepSeek-R1`; CLI: `openclaw onboard --auth-choice huggingface-api-key`. See [Hugging Face (Inference)](/providers/huggingface). +- Hugging Face Inference example model: `huggingface/deepseek-ai/DeepSeek-R1`; CLI: `openclaw onboard --auth-choice huggingface-api-key`. See [Hugging Face (Inference)](/providers/huggingface). ## Providers via `models.providers` (custom/base URL) Use `models.providers` (or `models.json`) to add **custom** providers or OpenAI/Anthropic‑compatible proxies. +Many of the bundled provider plugins below already publish a default catalog. +Use explicit `models.providers.` entries only when you want to override the +default base URL, headers, or model list. + ### Moonshot AI (Kimi) Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider: @@ -275,10 +300,9 @@ Kimi Coding uses Moonshot AI's Anthropic-compatible endpoint: ### Qwen OAuth (free tier) Qwen provides OAuth access to Qwen Coder + Vision via a device-code flow. -Enable the bundled plugin, then log in: +The bundled provider plugin is enabled by default, so just log in: ```bash -openclaw plugins enable qwen-portal-auth openclaw models auth login --provider qwen-portal --set-default ``` diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index d9026e5e4fc..23eb378193e 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -164,12 +164,29 @@ Important trust note: - [Nostr](/channels/nostr) — `@openclaw/nostr` - [Zalo](/channels/zalo) — `@openclaw/zalo` - [Microsoft Teams](/channels/msteams) — `@openclaw/msteams` +- BytePlus provider catalog — bundled as `byteplus` (enabled by default) +- Cloudflare AI Gateway provider catalog — bundled as `cloudflare-ai-gateway` (enabled by default) - Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default) - Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default) - GitHub Copilot provider runtime — bundled as `github-copilot` (enabled by default) +- Hugging Face provider catalog — bundled as `huggingface` (enabled by default) +- Kilo Gateway provider runtime — bundled as `kilocode` (enabled by default) +- Kimi Coding provider catalog — bundled as `kimi-coding` (enabled by default) +- MiniMax provider catalog — bundled as `minimax` (enabled by default) +- MiniMax OAuth (provider auth + catalog) — bundled as `minimax-portal-auth` (enabled by default) +- Model Studio provider catalog — bundled as `modelstudio` (enabled by default) +- Moonshot provider runtime — bundled as `moonshot` (enabled by default) +- NVIDIA provider catalog — bundled as `nvidia` (enabled by default) - OpenAI Codex provider runtime — bundled as `openai-codex` (enabled by default) - OpenRouter provider runtime — bundled as `openrouter` (enabled by default) -- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default) +- Qianfan provider catalog — bundled as `qianfan` (enabled by default) +- Qwen OAuth (provider auth + catalog) — bundled as `qwen-portal-auth` (enabled by default) +- Synthetic provider catalog — bundled as `synthetic` (enabled by default) +- Together provider catalog — bundled as `together` (enabled by default) +- Venice provider catalog — bundled as `venice` (enabled by default) +- Vercel AI Gateway provider catalog — bundled as `vercel-ai-gateway` (enabled by default) +- Volcengine provider catalog — bundled as `volcengine` (enabled by default) +- Xiaomi provider catalog — bundled as `xiaomi` (enabled by default) - Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default) Native OpenClaw plugins are **TypeScript modules** loaded at runtime via jiti. @@ -323,6 +340,16 @@ api.registerProvider({ - OpenRouter uses `capabilities`, `wrapStreamFn`, and `isCacheTtlEligible` to keep provider-specific request headers, routing metadata, reasoning patches, and prompt-cache policy out of core. +- Moonshot uses `catalog` plus `wrapStreamFn` because it still uses the shared + OpenAI transport but needs provider-owned thinking payload normalization. +- Kilocode uses `catalog`, `capabilities`, `wrapStreamFn`, and + `isCacheTtlEligible` because it needs provider-owned request headers, + reasoning payload normalization, Gemini transcript hints, and Anthropic + cache-TTL gating. +- Catalog-only bundled providers such as `byteplus`, `cloudflare-ai-gateway`, + `huggingface`, `kimi-coding`, `minimax`, `minimax-portal`, `modelstudio`, + `nvidia`, `qianfan`, `qwen-portal`, `synthetic`, `together`, `venice`, + `vercel-ai-gateway`, `volcengine`, and `xiaomi` use `catalog` only. ## Load pipeline @@ -561,18 +588,44 @@ OpenClaw scans, in order: - `~/.openclaw/extensions/*.ts` - `~/.openclaw/extensions/*/index.ts` -4. Bundled extensions (shipped with OpenClaw, mostly disabled by default) +4. Bundled extensions (shipped with OpenClaw; mixed default-on/default-off) - `/extensions/*` -Most bundled plugins must be enabled explicitly via -`plugins.entries..enabled` or `openclaw plugins enable `. +Many bundled provider plugins are enabled by default so model catalogs/runtime +hooks stay available without extra setup. Others still require explicit +enablement via `plugins.entries..enabled` or +`openclaw plugins enable `. -Default-on bundled plugin exceptions: +Default-on bundled plugin examples: +- `byteplus` +- `cloudflare-ai-gateway` - `device-pair` +- `github-copilot` +- `huggingface` +- `kilocode` +- `kimi-coding` +- `minimax` +- `minimax-portal-auth` +- `modelstudio` +- `moonshot` +- `nvidia` +- `ollama` +- `openai-codex` +- `openrouter` - `phone-control` +- `qianfan` +- `qwen-portal-auth` +- `sglang` +- `synthetic` - `talk-voice` +- `together` +- `venice` +- `vercel-ai-gateway` +- `vllm` +- `volcengine` +- `xiaomi` - active memory slot plugin (default slot: `memory-core`) Installed plugins are enabled by default, but can be disabled the same way. @@ -628,9 +681,8 @@ Enablement is resolved after discovery: - channel config implicitly enables the bundled channel plugin - exclusive slots can force-enable the selected plugin for that slot -In current core, bundled default-on ids include local/provider helpers such as -`ollama`, `sglang`, `vllm`, plus `device-pair`, `phone-control`, and -`talk-voice`. +In current core, bundled default-on ids include the local/provider helpers +above plus the active memory slot plugin. ### Package packs diff --git a/extensions/minimax-portal-auth/index.ts b/extensions/minimax-portal-auth/index.ts index ac36106a42e..eda0b72227c 100644 --- a/extensions/minimax-portal-auth/index.ts +++ b/extensions/minimax-portal-auth/index.ts @@ -6,6 +6,9 @@ import { type ProviderAuthResult, type ProviderCatalogContext, } from "openclaw/plugin-sdk/minimax-portal-auth"; +import { ensureAuthProfileStore, listProfilesForProvider } from "../../src/agents/auth-profiles.js"; +import { MINIMAX_OAUTH_MARKER } from "../../src/agents/model-auth-markers.js"; +import { buildMinimaxPortalProvider } from "../../src/agents/models-config.providers.static.js"; import { loginMiniMaxPortalOAuth, type MiniMaxRegion } from "./oauth.js"; const PROVIDER_ID = "minimax-portal"; @@ -13,8 +16,6 @@ const PROVIDER_LABEL = "MiniMax"; const DEFAULT_MODEL = "MiniMax-M2.5"; const DEFAULT_BASE_URL_CN = "https://api.minimaxi.com/anthropic"; const DEFAULT_BASE_URL_GLOBAL = "https://api.minimax.io/anthropic"; -const DEFAULT_CONTEXT_WINDOW = 200000; -const DEFAULT_MAX_TOKENS = 8192; function getDefaultBaseUrl(region: MiniMaxRegion): string { return region === "cn" ? DEFAULT_BASE_URL_CN : DEFAULT_BASE_URL_GLOBAL; @@ -24,55 +25,24 @@ function modelRef(modelId: string): string { return `${PROVIDER_ID}/${modelId}`; } -function buildModelDefinition(params: { - id: string; - name: string; - input: Array<"text" | "image">; - reasoning?: boolean; -}) { - return { - id: params.id, - name: params.name, - reasoning: params.reasoning ?? false, - input: params.input, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: DEFAULT_CONTEXT_WINDOW, - maxTokens: DEFAULT_MAX_TOKENS, - }; -} - function buildProviderCatalog(params: { baseUrl: string; apiKey: string }) { return { + ...buildMinimaxPortalProvider(), baseUrl: params.baseUrl, apiKey: params.apiKey, - api: "anthropic-messages" as const, - models: [ - buildModelDefinition({ - id: "MiniMax-M2.5", - name: "MiniMax M2.5", - input: ["text"], - }), - buildModelDefinition({ - id: "MiniMax-M2.5-highspeed", - name: "MiniMax M2.5 Highspeed", - input: ["text"], - reasoning: true, - }), - buildModelDefinition({ - id: "MiniMax-M2.5-Lightning", - name: "MiniMax M2.5 Lightning", - input: ["text"], - reasoning: true, - }), - ], }; } function resolveCatalog(ctx: ProviderCatalogContext) { const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID]; - const apiKey = - ctx.resolveProviderApiKey(PROVIDER_ID).apiKey ?? - (typeof explicitProvider?.apiKey === "string" ? explicitProvider.apiKey.trim() : undefined); + const envApiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + const authStore = ensureAuthProfileStore(ctx.agentDir, { + allowKeychainPrompt: false, + }); + const hasProfiles = listProfilesForProvider(authStore, PROVIDER_ID).length > 0; + const explicitApiKey = + typeof explicitProvider?.apiKey === "string" ? explicitProvider.apiKey.trim() : undefined; + const apiKey = envApiKey ?? explicitApiKey ?? (hasProfiles ? MINIMAX_OAUTH_MARKER : undefined); if (!apiKey) { return null; } @@ -167,7 +137,6 @@ const minimaxPortalPlugin = { id: PROVIDER_ID, label: PROVIDER_LABEL, docsPath: "/providers/minimax", - aliases: ["minimax"], catalog: { run: async (ctx: ProviderCatalogContext) => resolveCatalog(ctx), }, diff --git a/extensions/qwen-portal-auth/index.ts b/extensions/qwen-portal-auth/index.ts index c5722e0dbf9..919fa927e57 100644 --- a/extensions/qwen-portal-auth/index.ts +++ b/extensions/qwen-portal-auth/index.ts @@ -5,6 +5,8 @@ import { type ProviderAuthContext, type ProviderCatalogContext, } from "openclaw/plugin-sdk/qwen-portal-auth"; +import { ensureAuthProfileStore, listProfilesForProvider } from "../../src/agents/auth-profiles.js"; +import { QWEN_OAUTH_MARKER } from "../../src/agents/model-auth-markers.js"; import { loginQwenPortalOAuth } from "./oauth.js"; const PROVIDER_ID = "qwen-portal"; @@ -58,9 +60,14 @@ function buildProviderCatalog(params: { baseUrl: string; apiKey: string }) { function resolveCatalog(ctx: ProviderCatalogContext) { const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID]; - const apiKey = - ctx.resolveProviderApiKey(PROVIDER_ID).apiKey ?? - (typeof explicitProvider?.apiKey === "string" ? explicitProvider.apiKey.trim() : undefined); + const envApiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + const authStore = ensureAuthProfileStore(ctx.agentDir, { + allowKeychainPrompt: false, + }); + const hasProfiles = listProfilesForProvider(authStore, PROVIDER_ID).length > 0; + const explicitApiKey = + typeof explicitProvider?.apiKey === "string" ? explicitProvider.apiKey.trim() : undefined; + const apiKey = envApiKey ?? explicitApiKey ?? (hasProfiles ? QWEN_OAUTH_MARKER : undefined); if (!apiKey) { return null; } diff --git a/src/agents/models-config.providers.moonshot.test.ts b/src/agents/models-config.providers.moonshot.test.ts index c235266800a..1d0d29d1b30 100644 --- a/src/agents/models-config.providers.moonshot.test.ts +++ b/src/agents/models-config.providers.moonshot.test.ts @@ -7,10 +7,8 @@ import { MOONSHOT_CN_BASE_URL, } from "../commands/onboard-auth.models.js"; import { captureEnv } from "../test-utils/env.js"; -import { - applyNativeStreamingUsageCompat, - resolveImplicitProviders, -} from "./models-config.providers.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; +import { applyNativeStreamingUsageCompat } from "./models-config.providers.js"; import { buildMoonshotProvider } from "./models-config.providers.static.js"; describe("moonshot implicit provider (#33637)", () => { @@ -20,7 +18,7 @@ describe("moonshot implicit provider (#33637)", () => { process.env.MOONSHOT_API_KEY = "sk-test-cn"; try { - const providers = await resolveImplicitProviders({ + const providers = await resolveImplicitProvidersForTest({ agentDir, explicitProviders: { moonshot: { @@ -55,7 +53,7 @@ describe("moonshot implicit provider (#33637)", () => { process.env.MOONSHOT_API_KEY = "sk-test-custom"; try { - const providers = await resolveImplicitProviders({ + const providers = await resolveImplicitProvidersForTest({ agentDir, explicitProviders: { moonshot: { @@ -79,7 +77,7 @@ describe("moonshot implicit provider (#33637)", () => { process.env.MOONSHOT_API_KEY = "sk-test"; try { - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.moonshot).toBeDefined(); expect(providers?.moonshot?.baseUrl).toBe(MOONSHOT_AI_BASE_URL); expect(providers?.moonshot?.models?.[0]?.compat?.supportsUsageInStreaming).toBeUndefined(); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 29ffd29e87c..264cb402b47 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -4,35 +4,9 @@ import { isRecord } from "../utils.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; import { discoverBedrockModels } from "./bedrock-discovery.js"; -import { - buildCloudflareAiGatewayModelDefinition, - resolveCloudflareAiGatewayBaseUrl, -} from "./cloudflare-ai-gateway.js"; import { normalizeGoogleModelId } from "./model-id-normalization.js"; +import { resolveOllamaApiBase } from "./models-config.providers.discovery.js"; import { - buildHuggingfaceProvider, - buildKilocodeProviderWithDiscovery, - buildVeniceProvider, - buildVercelAiGatewayProvider, - resolveOllamaApiBase, -} from "./models-config.providers.discovery.js"; -import { - buildBytePlusCodingProvider, - buildBytePlusProvider, - buildDoubaoCodingProvider, - buildDoubaoProvider, - buildKimiCodingProvider, - buildKilocodeProvider, - buildMinimaxPortalProvider, - buildMinimaxProvider, - buildModelStudioProvider, - buildMoonshotProvider, - buildNvidiaProvider, - buildQianfanProvider, - buildQwenPortalProvider, - buildSyntheticProvider, - buildTogetherProvider, - buildXiaomiProvider, QIANFAN_BASE_URL, QIANFAN_DEFAULT_MODEL_ID, XIAOMI_DEFAULT_MODEL_ID, @@ -57,8 +31,6 @@ import { runProviderCatalog, } from "../plugins/provider-discovery.js"; import { - MINIMAX_OAUTH_MARKER, - QWEN_OAUTH_MARKER, isNonSecretApiKeyMarker, resolveNonEnvSecretRefApiKeyMarker, resolveNonEnvSecretRefHeaderValueMarker, @@ -647,47 +619,6 @@ type ImplicitProviderContext = ImplicitProviderParams & { resolveProviderApiKey: ProviderApiKeyResolver; }; -type ImplicitProviderLoader = ( - ctx: ImplicitProviderContext, -) => Promise | undefined>; - -function withApiKey( - providerKey: string, - build: (params: { - apiKey: string; - discoveryApiKey?: string; - explicitProvider?: ProviderConfig; - }) => ProviderConfig | Promise, -): ImplicitProviderLoader { - return async (ctx) => { - const { apiKey, discoveryApiKey } = ctx.resolveProviderApiKey(providerKey); - if (!apiKey) { - return undefined; - } - return { - [providerKey]: await build({ - apiKey, - discoveryApiKey, - explicitProvider: ctx.explicitProviders?.[providerKey], - }), - }; - }; -} - -function withProfilePresence( - providerKey: string, - build: () => ProviderConfig | Promise, -): ImplicitProviderLoader { - return async (ctx) => { - if (listProfilesForProvider(ctx.authStore, providerKey).length === 0) { - return undefined; - } - return { - [providerKey]: await build(), - }; - }; -} - function mergeImplicitProviderSet( target: Record, additions: Record | undefined, @@ -700,155 +631,6 @@ function mergeImplicitProviderSet( } } -const SIMPLE_IMPLICIT_PROVIDER_LOADERS: ImplicitProviderLoader[] = [ - withApiKey("minimax", async ({ apiKey }) => ({ ...buildMinimaxProvider(), apiKey })), - withApiKey("moonshot", async ({ apiKey, explicitProvider }) => { - const explicitBaseUrl = explicitProvider?.baseUrl; - return { - ...buildMoonshotProvider(), - ...(typeof explicitBaseUrl === "string" && explicitBaseUrl.trim() - ? { baseUrl: explicitBaseUrl.trim() } - : {}), - apiKey, - }; - }), - withApiKey("kimi-coding", async ({ apiKey, explicitProvider }) => { - const builtInProvider = buildKimiCodingProvider(); - const explicitBaseUrl = explicitProvider?.baseUrl; - const explicitHeaders = isRecord(explicitProvider?.headers) - ? (explicitProvider.headers as ProviderConfig["headers"]) - : undefined; - return { - ...builtInProvider, - ...(typeof explicitBaseUrl === "string" && explicitBaseUrl.trim() - ? { baseUrl: explicitBaseUrl.trim() } - : {}), - ...(explicitHeaders - ? { - headers: { - ...builtInProvider.headers, - ...explicitHeaders, - }, - } - : {}), - apiKey, - }; - }), - withApiKey("synthetic", async ({ apiKey }) => ({ ...buildSyntheticProvider(), apiKey })), - withApiKey("venice", async ({ apiKey }) => ({ ...(await buildVeniceProvider()), apiKey })), - withApiKey("xiaomi", async ({ apiKey }) => ({ ...buildXiaomiProvider(), apiKey })), - withApiKey("vercel-ai-gateway", async ({ apiKey }) => ({ - ...(await buildVercelAiGatewayProvider()), - apiKey, - })), - withApiKey("together", async ({ apiKey }) => ({ ...buildTogetherProvider(), apiKey })), - withApiKey("huggingface", async ({ apiKey, discoveryApiKey }) => ({ - ...(await buildHuggingfaceProvider(discoveryApiKey)), - apiKey, - })), - withApiKey("qianfan", async ({ apiKey }) => ({ ...buildQianfanProvider(), apiKey })), - withApiKey("modelstudio", async ({ apiKey, explicitProvider }) => { - const explicitBaseUrl = explicitProvider?.baseUrl; - return { - ...buildModelStudioProvider(), - ...(typeof explicitBaseUrl === "string" && explicitBaseUrl.trim() - ? { baseUrl: explicitBaseUrl.trim() } - : {}), - apiKey, - }; - }), - withApiKey("nvidia", async ({ apiKey }) => ({ ...buildNvidiaProvider(), apiKey })), - withApiKey("kilocode", async ({ apiKey }) => ({ - ...(await buildKilocodeProviderWithDiscovery()), - apiKey, - })), -]; - -const PROFILE_IMPLICIT_PROVIDER_LOADERS: ImplicitProviderLoader[] = [ - async (ctx) => { - const envKey = resolveEnvApiKeyVarName("minimax-portal", ctx.env); - const hasProfiles = listProfilesForProvider(ctx.authStore, "minimax-portal").length > 0; - if (!envKey && !hasProfiles) { - return undefined; - } - return { - "minimax-portal": { - ...buildMinimaxPortalProvider(), - apiKey: MINIMAX_OAUTH_MARKER, - }, - }; - }, - withProfilePresence("qwen-portal", async () => ({ - ...buildQwenPortalProvider(), - apiKey: QWEN_OAUTH_MARKER, - })), -]; - -const PAIRED_IMPLICIT_PROVIDER_LOADERS: ImplicitProviderLoader[] = [ - async (ctx) => { - const volcengineKey = ctx.resolveProviderApiKey("volcengine").apiKey; - if (!volcengineKey) { - return undefined; - } - return { - volcengine: { ...buildDoubaoProvider(), apiKey: volcengineKey }, - "volcengine-plan": { - ...buildDoubaoCodingProvider(), - apiKey: volcengineKey, - }, - }; - }, - async (ctx) => { - const byteplusKey = ctx.resolveProviderApiKey("byteplus").apiKey; - if (!byteplusKey) { - return undefined; - } - return { - byteplus: { ...buildBytePlusProvider(), apiKey: byteplusKey }, - "byteplus-plan": { - ...buildBytePlusCodingProvider(), - apiKey: byteplusKey, - }, - }; - }, -]; - -async function resolveCloudflareAiGatewayImplicitProvider( - ctx: ImplicitProviderContext, -): Promise | undefined> { - const cloudflareProfiles = listProfilesForProvider(ctx.authStore, "cloudflare-ai-gateway"); - for (const profileId of cloudflareProfiles) { - const cred = ctx.authStore.profiles[profileId]; - if (cred?.type !== "api_key") { - continue; - } - const accountId = cred.metadata?.accountId?.trim(); - const gatewayId = cred.metadata?.gatewayId?.trim(); - if (!accountId || !gatewayId) { - continue; - } - const baseUrl = resolveCloudflareAiGatewayBaseUrl({ accountId, gatewayId }); - if (!baseUrl) { - continue; - } - const envVarApiKey = resolveEnvApiKeyVarName("cloudflare-ai-gateway", ctx.env); - const profileApiKey = resolveApiKeyFromCredential(cred, ctx.env)?.apiKey; - const apiKey = envVarApiKey ?? profileApiKey ?? ""; - if (!apiKey) { - continue; - } - return { - "cloudflare-ai-gateway": { - baseUrl, - api: "anthropic-messages", - apiKey, - models: [buildCloudflareAiGatewayModelDefinition()], - }, - }; - } - return undefined; -} - async function resolvePluginImplicitProviders( ctx: ImplicitProviderContext, order: import("../plugins/types.js").ProviderDiscoveryOrder, @@ -860,10 +642,23 @@ async function resolvePluginImplicitProviders( }); const byOrder = groupPluginDiscoveryProvidersByOrder(providers); const discovered: Record = {}; + const catalogConfig = + ctx.explicitProviders && Object.keys(ctx.explicitProviders).length > 0 + ? { + ...ctx.config, + models: { + ...ctx.config?.models, + providers: { + ...ctx.config?.models?.providers, + ...ctx.explicitProviders, + }, + }, + } + : (ctx.config ?? {}); for (const provider of byOrder[order]) { const result = await runProviderCatalog({ provider, - config: ctx.config ?? {}, + config: catalogConfig, agentDir: ctx.agentDir, workspaceDir: ctx.workspaceDir, env: ctx.env, @@ -912,19 +707,9 @@ export async function resolveImplicitProviders( resolveProviderApiKey, }; - for (const loader of SIMPLE_IMPLICIT_PROVIDER_LOADERS) { - mergeImplicitProviderSet(providers, await loader(context)); - } mergeImplicitProviderSet(providers, await resolvePluginImplicitProviders(context, "simple")); - for (const loader of PROFILE_IMPLICIT_PROVIDER_LOADERS) { - mergeImplicitProviderSet(providers, await loader(context)); - } mergeImplicitProviderSet(providers, await resolvePluginImplicitProviders(context, "profile")); - for (const loader of PAIRED_IMPLICIT_PROVIDER_LOADERS) { - mergeImplicitProviderSet(providers, await loader(context)); - } mergeImplicitProviderSet(providers, await resolvePluginImplicitProviders(context, "paired")); - mergeImplicitProviderSet(providers, await resolveCloudflareAiGatewayImplicitProvider(context)); mergeImplicitProviderSet(providers, await resolvePluginImplicitProviders(context, "late")); const implicitBedrock = await resolveImplicitBedrockProvider({ diff --git a/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts b/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts index 5a36c9c5a4d..8a09d9af547 100644 --- a/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts @@ -2,6 +2,7 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import type { Context, Model } from "@mariozechner/pi-ai"; import { createAssistantMessageEventStream } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; import { applyExtraParamsToAgent } from "./extra-params.js"; type StreamPayload = { @@ -17,8 +18,17 @@ function runOpenRouterPayload(payload: StreamPayload, modelId: string) { return createAssistantMessageEventStream(); }; const agent = { streamFn: baseStreamFn }; + const cfg = { + plugins: { + entries: { + openrouter: { + enabled: true, + }, + }, + }, + } satisfies OpenClawConfig; - applyExtraParamsToAgent(agent, undefined, "openrouter", modelId); + applyExtraParamsToAgent(agent, cfg, "openrouter", modelId); const model = { api: "openai-completions", diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index be773071fbe..7f329302803 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -20,8 +20,8 @@ import { import { log } from "./logger.js"; import { createMoonshotThinkingWrapper, - createSiliconFlowThinkingWrapper, resolveMoonshotThinkingType, + createSiliconFlowThinkingWrapper, shouldApplyMoonshotPayloadCompat, shouldApplySiliconFlowThinkingOffCompat, } from "./moonshot-stream-wrappers.js"; @@ -33,7 +33,6 @@ import { resolveOpenAIFastMode, resolveOpenAIServiceTier, } from "./openai-stream-wrappers.js"; -import { createKilocodeWrapper, isProxyReasoningUnsupported } from "./proxy-stream-wrappers.js"; /** * Resolve provider-specific extra params from model config. @@ -366,42 +365,33 @@ export function applyExtraParamsToAgent( agent.streamFn = createSiliconFlowThinkingWrapper(agent.streamFn); } - if (shouldApplyMoonshotPayloadCompat({ provider, modelId })) { - const moonshotThinkingType = resolveMoonshotThinkingType({ + agent.streamFn = createAnthropicToolPayloadCompatibilityWrapper(agent.streamFn); + const providerStreamBase = agent.streamFn; + const pluginWrappedStreamFn = wrapProviderStreamFn({ + provider, + config: cfg, + context: { + config: cfg, + provider, + modelId, + extraParams: effectiveExtraParams, + thinkingLevel, + streamFn: providerStreamBase, + }, + }); + agent.streamFn = pluginWrappedStreamFn ?? providerStreamBase; + const providerWrapperHandled = + pluginWrappedStreamFn !== undefined && pluginWrappedStreamFn !== providerStreamBase; + + if (!providerWrapperHandled && shouldApplyMoonshotPayloadCompat({ provider, modelId })) { + // Preserve the legacy Moonshot compatibility path when no plugin wrapper + // actually handled the stream function. This covers tests/disabled plugins + // and Ollama Cloud Kimi models until they gain a dedicated runtime hook. + const thinkingType = resolveMoonshotThinkingType({ configuredThinking: effectiveExtraParams?.thinking, thinkingLevel, }); - if (moonshotThinkingType) { - log.debug( - `applying Moonshot thinking=${moonshotThinkingType} payload wrapper for ${provider}/${modelId}`, - ); - } - agent.streamFn = createMoonshotThinkingWrapper(agent.streamFn, moonshotThinkingType); - } - - agent.streamFn = createAnthropicToolPayloadCompatibilityWrapper(agent.streamFn); - agent.streamFn = - wrapProviderStreamFn({ - provider, - config: cfg, - context: { - config: cfg, - provider, - modelId, - extraParams: effectiveExtraParams, - thinkingLevel, - streamFn: agent.streamFn, - }, - }) ?? agent.streamFn; - - if (provider === "kilocode") { - log.debug(`applying Kilocode feature header for ${provider}/${modelId}`); - // kilo/auto is a dynamic routing model — skip reasoning injection - // (same rationale as OpenRouter "auto"). See: openclaw/openclaw#24851 - // Also skip for models known to reject reasoning.effort (e.g. x-ai/*). - const kilocodeThinkingLevel = - modelId === "kilo/auto" || isProxyReasoningUnsupported(modelId) ? undefined : thinkingLevel; - agent.streamFn = createKilocodeWrapper(agent.streamFn, kilocodeThinkingLevel); + agent.streamFn = createMoonshotThinkingWrapper(agent.streamFn, thinkingType); } if (provider === "amazon-bedrock" && !isAnthropicBedrockModel(modelId)) { diff --git a/src/agents/provider-capabilities.test.ts b/src/agents/provider-capabilities.test.ts index f2e5d32e70e..8dee8776835 100644 --- a/src/agents/provider-capabilities.test.ts +++ b/src/agents/provider-capabilities.test.ts @@ -16,6 +16,15 @@ const resolveProviderCapabilitiesWithPluginMock = vi.fn((params: { provider: str return { dropThinkingBlockModelHints: ["claude"], }; + case "kilocode": + return { + geminiThoughtSignatureSanitization: true, + geminiThoughtSignatureModelHints: ["gemini"], + }; + case "kimi-coding": + return { + preserveAnthropicThinkingSignatures: false, + }; default: return undefined; } diff --git a/src/agents/provider-capabilities.ts b/src/agents/provider-capabilities.ts index 4b6022179c8..00a09b2386c 100644 --- a/src/agents/provider-capabilities.ts +++ b/src/agents/provider-capabilities.ts @@ -36,11 +36,6 @@ const PROVIDER_CAPABILITIES: Record> = { providerFamily: "anthropic", dropThinkingBlockModelHints: ["claude"], }, - // kimi-coding natively supports Anthropic tool framing (input_schema); - // converting to OpenAI format causes XML text fallback instead of tool_use blocks. - "kimi-coding": { - preserveAnthropicThinkingSignatures: false, - }, mistral: { transcriptToolCallIdMode: "strict9", transcriptToolCallIdModelHints: [ @@ -66,10 +61,6 @@ const PROVIDER_CAPABILITIES: Record> = { geminiThoughtSignatureSanitization: true, geminiThoughtSignatureModelHints: ["gemini"], }, - kilocode: { - geminiThoughtSignatureSanitization: true, - geminiThoughtSignatureModelHints: ["gemini"], - }, }; export function resolveProviderCapabilities(provider?: string | null): ProviderCapabilities { diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 6a0cbbdf988..16345b1b986 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -24,15 +24,33 @@ export type NormalizedPluginsConfig = { }; export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ + "byteplus", + "cloudflare-ai-gateway", "device-pair", "github-copilot", + "huggingface", + "kilocode", + "kimi-coding", + "minimax", + "minimax-portal-auth", + "modelstudio", + "moonshot", + "nvidia", "ollama", "openai-codex", "openrouter", "phone-control", + "qianfan", + "qwen-portal-auth", "sglang", + "synthetic", "talk-voice", + "together", + "venice", + "vercel-ai-gateway", "vllm", + "volcengine", + "xiaomi", ]); const normalizeList = (value: unknown): string[] => { From 684e5ea249242428ee7721aa2742d4519cdaab4b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 16:09:21 -0700 Subject: [PATCH 037/943] build(plugins): add bundled provider plugin packages --- .github/labeler.yml | 64 +++++++++++++++ extensions/byteplus/index.ts | 40 +++++++++ extensions/byteplus/openclaw.plugin.json | 9 ++ extensions/byteplus/package.json | 12 +++ extensions/cloudflare-ai-gateway/index.ts | 82 +++++++++++++++++++ .../openclaw.plugin.json | 9 ++ extensions/cloudflare-ai-gateway/package.json | 12 +++ extensions/huggingface/index.ts | 37 +++++++++ extensions/huggingface/openclaw.plugin.json | 9 ++ extensions/huggingface/package.json | 12 +++ extensions/kilocode/index.ts | 53 ++++++++++++ extensions/kilocode/openclaw.plugin.json | 9 ++ extensions/kilocode/package.json | 12 +++ extensions/kimi-coding/index.ts | 58 +++++++++++++ extensions/kimi-coding/openclaw.plugin.json | 9 ++ extensions/kimi-coding/package.json | 12 +++ extensions/minimax/index.ts | 37 +++++++++ extensions/minimax/openclaw.plugin.json | 9 ++ extensions/minimax/package.json | 12 +++ extensions/modelstudio/index.ts | 41 ++++++++++ extensions/modelstudio/openclaw.plugin.json | 9 ++ extensions/modelstudio/package.json | 12 +++ extensions/moonshot/index.ts | 52 ++++++++++++ extensions/moonshot/openclaw.plugin.json | 9 ++ extensions/moonshot/package.json | 12 +++ extensions/nvidia/index.ts | 37 +++++++++ extensions/nvidia/openclaw.plugin.json | 9 ++ extensions/nvidia/package.json | 12 +++ extensions/qianfan/index.ts | 37 +++++++++ extensions/qianfan/openclaw.plugin.json | 9 ++ extensions/qianfan/package.json | 12 +++ extensions/synthetic/index.ts | 37 +++++++++ extensions/synthetic/openclaw.plugin.json | 9 ++ extensions/synthetic/package.json | 12 +++ extensions/together/index.ts | 37 +++++++++ extensions/together/openclaw.plugin.json | 9 ++ extensions/together/package.json | 12 +++ extensions/venice/index.ts | 37 +++++++++ extensions/venice/openclaw.plugin.json | 9 ++ extensions/venice/package.json | 12 +++ extensions/vercel-ai-gateway/index.ts | 37 +++++++++ .../vercel-ai-gateway/openclaw.plugin.json | 9 ++ extensions/vercel-ai-gateway/package.json | 12 +++ extensions/volcengine/index.ts | 40 +++++++++ extensions/volcengine/openclaw.plugin.json | 9 ++ extensions/volcengine/package.json | 12 +++ extensions/xiaomi/index.ts | 37 +++++++++ extensions/xiaomi/openclaw.plugin.json | 9 ++ extensions/xiaomi/package.json | 12 +++ 49 files changed, 1099 insertions(+) create mode 100644 extensions/byteplus/index.ts create mode 100644 extensions/byteplus/openclaw.plugin.json create mode 100644 extensions/byteplus/package.json create mode 100644 extensions/cloudflare-ai-gateway/index.ts create mode 100644 extensions/cloudflare-ai-gateway/openclaw.plugin.json create mode 100644 extensions/cloudflare-ai-gateway/package.json create mode 100644 extensions/huggingface/index.ts create mode 100644 extensions/huggingface/openclaw.plugin.json create mode 100644 extensions/huggingface/package.json create mode 100644 extensions/kilocode/index.ts create mode 100644 extensions/kilocode/openclaw.plugin.json create mode 100644 extensions/kilocode/package.json create mode 100644 extensions/kimi-coding/index.ts create mode 100644 extensions/kimi-coding/openclaw.plugin.json create mode 100644 extensions/kimi-coding/package.json create mode 100644 extensions/minimax/index.ts create mode 100644 extensions/minimax/openclaw.plugin.json create mode 100644 extensions/minimax/package.json create mode 100644 extensions/modelstudio/index.ts create mode 100644 extensions/modelstudio/openclaw.plugin.json create mode 100644 extensions/modelstudio/package.json create mode 100644 extensions/moonshot/index.ts create mode 100644 extensions/moonshot/openclaw.plugin.json create mode 100644 extensions/moonshot/package.json create mode 100644 extensions/nvidia/index.ts create mode 100644 extensions/nvidia/openclaw.plugin.json create mode 100644 extensions/nvidia/package.json create mode 100644 extensions/qianfan/index.ts create mode 100644 extensions/qianfan/openclaw.plugin.json create mode 100644 extensions/qianfan/package.json create mode 100644 extensions/synthetic/index.ts create mode 100644 extensions/synthetic/openclaw.plugin.json create mode 100644 extensions/synthetic/package.json create mode 100644 extensions/together/index.ts create mode 100644 extensions/together/openclaw.plugin.json create mode 100644 extensions/together/package.json create mode 100644 extensions/venice/index.ts create mode 100644 extensions/venice/openclaw.plugin.json create mode 100644 extensions/venice/package.json create mode 100644 extensions/vercel-ai-gateway/index.ts create mode 100644 extensions/vercel-ai-gateway/openclaw.plugin.json create mode 100644 extensions/vercel-ai-gateway/package.json create mode 100644 extensions/volcengine/index.ts create mode 100644 extensions/volcengine/openclaw.plugin.json create mode 100644 extensions/volcengine/package.json create mode 100644 extensions/xiaomi/index.ts create mode 100644 extensions/xiaomi/openclaw.plugin.json create mode 100644 extensions/xiaomi/package.json diff --git a/.github/labeler.yml b/.github/labeler.yml index 91c202b7ed6..08ede2a1ca5 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -238,15 +238,79 @@ - changed-files: - any-glob-to-any-file: - "extensions/acpx/**" +"extensions: byteplus": + - changed-files: + - any-glob-to-any-file: + - "extensions/byteplus/**" +"extensions: cloudflare-ai-gateway": + - changed-files: + - any-glob-to-any-file: + - "extensions/cloudflare-ai-gateway/**" "extensions: minimax-portal-auth": - changed-files: - any-glob-to-any-file: - "extensions/minimax-portal-auth/**" +"extensions: huggingface": + - changed-files: + - any-glob-to-any-file: + - "extensions/huggingface/**" +"extensions: kilocode": + - changed-files: + - any-glob-to-any-file: + - "extensions/kilocode/**" +"extensions: kimi-coding": + - changed-files: + - any-glob-to-any-file: + - "extensions/kimi-coding/**" +"extensions: minimax": + - changed-files: + - any-glob-to-any-file: + - "extensions/minimax/**" +"extensions: modelstudio": + - changed-files: + - any-glob-to-any-file: + - "extensions/modelstudio/**" +"extensions: moonshot": + - changed-files: + - any-glob-to-any-file: + - "extensions/moonshot/**" +"extensions: nvidia": + - changed-files: + - any-glob-to-any-file: + - "extensions/nvidia/**" "extensions: phone-control": - changed-files: - any-glob-to-any-file: - "extensions/phone-control/**" +"extensions: qianfan": + - changed-files: + - any-glob-to-any-file: + - "extensions/qianfan/**" +"extensions: synthetic": + - changed-files: + - any-glob-to-any-file: + - "extensions/synthetic/**" "extensions: talk-voice": - changed-files: - any-glob-to-any-file: - "extensions/talk-voice/**" +"extensions: together": + - changed-files: + - any-glob-to-any-file: + - "extensions/together/**" +"extensions: venice": + - changed-files: + - any-glob-to-any-file: + - "extensions/venice/**" +"extensions: vercel-ai-gateway": + - changed-files: + - any-glob-to-any-file: + - "extensions/vercel-ai-gateway/**" +"extensions: volcengine": + - changed-files: + - any-glob-to-any-file: + - "extensions/volcengine/**" +"extensions: xiaomi": + - changed-files: + - any-glob-to-any-file: + - "extensions/xiaomi/**" diff --git a/extensions/byteplus/index.ts b/extensions/byteplus/index.ts new file mode 100644 index 00000000000..35050f2c789 --- /dev/null +++ b/extensions/byteplus/index.ts @@ -0,0 +1,40 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { + buildBytePlusCodingProvider, + buildBytePlusProvider, +} from "../../src/agents/models-config.providers.static.js"; + +const PROVIDER_ID = "byteplus"; + +const byteplusPlugin = { + id: PROVIDER_ID, + name: "BytePlus Provider", + description: "Bundled BytePlus provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "BytePlus", + docsPath: "/concepts/model-providers#byteplus-international", + envVars: ["BYTEPLUS_API_KEY"], + auth: [], + catalog: { + order: "paired", + run: async (ctx) => { + const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + if (!apiKey) { + return null; + } + return { + providers: { + byteplus: { ...buildBytePlusProvider(), apiKey }, + "byteplus-plan": { ...buildBytePlusCodingProvider(), apiKey }, + }, + }; + }, + }, + }); + }, +}; + +export default byteplusPlugin; diff --git a/extensions/byteplus/openclaw.plugin.json b/extensions/byteplus/openclaw.plugin.json new file mode 100644 index 00000000000..8885280bf32 --- /dev/null +++ b/extensions/byteplus/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "byteplus", + "providers": ["byteplus", "byteplus-plan"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/byteplus/package.json b/extensions/byteplus/package.json new file mode 100644 index 00000000000..8eda5930c69 --- /dev/null +++ b/extensions/byteplus/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/byteplus-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw BytePlus provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/cloudflare-ai-gateway/index.ts b/extensions/cloudflare-ai-gateway/index.ts new file mode 100644 index 00000000000..173c9eaf48b --- /dev/null +++ b/extensions/cloudflare-ai-gateway/index.ts @@ -0,0 +1,82 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { ensureAuthProfileStore, listProfilesForProvider } from "../../src/agents/auth-profiles.js"; +import { + buildCloudflareAiGatewayModelDefinition, + resolveCloudflareAiGatewayBaseUrl, +} from "../../src/agents/cloudflare-ai-gateway.js"; +import { resolveNonEnvSecretRefApiKeyMarker } from "../../src/agents/model-auth-markers.js"; +import { coerceSecretRef } from "../../src/config/types.secrets.js"; + +const PROVIDER_ID = "cloudflare-ai-gateway"; +const PROVIDER_ENV_VAR = "CLOUDFLARE_AI_GATEWAY_API_KEY"; + +function resolveApiKeyFromCredential( + cred: ReturnType["profiles"][string] | undefined, +): string | undefined { + if (!cred || cred.type !== "api_key") { + return undefined; + } + + const keyRef = coerceSecretRef(cred.keyRef); + if (keyRef && keyRef.id.trim()) { + return keyRef.source === "env" + ? keyRef.id.trim() + : resolveNonEnvSecretRefApiKeyMarker(keyRef.source); + } + return cred.key?.trim() || undefined; +} + +const cloudflareAiGatewayPlugin = { + id: PROVIDER_ID, + name: "Cloudflare AI Gateway Provider", + description: "Bundled Cloudflare AI Gateway provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Cloudflare AI Gateway", + docsPath: "/providers/cloudflare-ai-gateway", + envVars: ["CLOUDFLARE_AI_GATEWAY_API_KEY"], + auth: [], + catalog: { + order: "late", + run: async (ctx) => { + const authStore = ensureAuthProfileStore(ctx.agentDir, { + allowKeychainPrompt: false, + }); + const envManagedApiKey = ctx.env[PROVIDER_ENV_VAR]?.trim() ? PROVIDER_ENV_VAR : undefined; + for (const profileId of listProfilesForProvider(authStore, PROVIDER_ID)) { + const cred = authStore.profiles[profileId]; + if (!cred || cred.type !== "api_key") { + continue; + } + const apiKey = envManagedApiKey ?? resolveApiKeyFromCredential(cred); + if (!apiKey) { + continue; + } + const accountId = cred.metadata?.accountId?.trim(); + const gatewayId = cred.metadata?.gatewayId?.trim(); + if (!accountId || !gatewayId) { + continue; + } + const baseUrl = resolveCloudflareAiGatewayBaseUrl({ accountId, gatewayId }); + if (!baseUrl) { + continue; + } + return { + provider: { + baseUrl, + api: "anthropic-messages", + apiKey, + models: [buildCloudflareAiGatewayModelDefinition()], + }, + }; + } + return null; + }, + }, + }); + }, +}; + +export default cloudflareAiGatewayPlugin; diff --git a/extensions/cloudflare-ai-gateway/openclaw.plugin.json b/extensions/cloudflare-ai-gateway/openclaw.plugin.json new file mode 100644 index 00000000000..fc7a41f77bb --- /dev/null +++ b/extensions/cloudflare-ai-gateway/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "cloudflare-ai-gateway", + "providers": ["cloudflare-ai-gateway"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/cloudflare-ai-gateway/package.json b/extensions/cloudflare-ai-gateway/package.json new file mode 100644 index 00000000000..288bc1c7203 --- /dev/null +++ b/extensions/cloudflare-ai-gateway/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/cloudflare-ai-gateway-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Cloudflare AI Gateway provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/huggingface/index.ts b/extensions/huggingface/index.ts new file mode 100644 index 00000000000..c6407954811 --- /dev/null +++ b/extensions/huggingface/index.ts @@ -0,0 +1,37 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { buildHuggingfaceProvider } from "../../src/agents/models-config.providers.discovery.js"; + +const PROVIDER_ID = "huggingface"; + +const huggingfacePlugin = { + id: PROVIDER_ID, + name: "Hugging Face Provider", + description: "Bundled Hugging Face provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Hugging Face", + docsPath: "/providers/huggingface", + envVars: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"], + auth: [], + catalog: { + order: "simple", + run: async (ctx) => { + const { apiKey, discoveryApiKey } = ctx.resolveProviderApiKey(PROVIDER_ID); + if (!apiKey) { + return null; + } + return { + provider: { + ...(await buildHuggingfaceProvider(discoveryApiKey)), + apiKey, + }, + }; + }, + }, + }); + }, +}; + +export default huggingfacePlugin; diff --git a/extensions/huggingface/openclaw.plugin.json b/extensions/huggingface/openclaw.plugin.json new file mode 100644 index 00000000000..4b68bcedb26 --- /dev/null +++ b/extensions/huggingface/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "huggingface", + "providers": ["huggingface"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/huggingface/package.json b/extensions/huggingface/package.json new file mode 100644 index 00000000000..7e58582f4f9 --- /dev/null +++ b/extensions/huggingface/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/huggingface-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Hugging Face provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/kilocode/index.ts b/extensions/kilocode/index.ts new file mode 100644 index 00000000000..10fc30f67d4 --- /dev/null +++ b/extensions/kilocode/index.ts @@ -0,0 +1,53 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { buildKilocodeProviderWithDiscovery } from "../../src/agents/models-config.providers.discovery.js"; +import { + createKilocodeWrapper, + isProxyReasoningUnsupported, +} from "../../src/agents/pi-embedded-runner/proxy-stream-wrappers.js"; + +const PROVIDER_ID = "kilocode"; + +const kilocodePlugin = { + id: PROVIDER_ID, + name: "Kilo Gateway Provider", + description: "Bundled Kilo Gateway provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Kilo Gateway", + docsPath: "/providers/kilocode", + envVars: ["KILOCODE_API_KEY"], + auth: [], + catalog: { + order: "simple", + run: async (ctx) => { + const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + if (!apiKey) { + return null; + } + return { + provider: { + ...(await buildKilocodeProviderWithDiscovery()), + apiKey, + }, + }; + }, + }, + capabilities: { + geminiThoughtSignatureSanitization: true, + geminiThoughtSignatureModelHints: ["gemini"], + }, + wrapStreamFn: (ctx) => { + const thinkingLevel = + ctx.modelId === "kilo/auto" || isProxyReasoningUnsupported(ctx.modelId) + ? undefined + : ctx.thinkingLevel; + return createKilocodeWrapper(ctx.streamFn, thinkingLevel); + }, + isCacheTtlEligible: (ctx) => ctx.modelId.startsWith("anthropic/"), + }); + }, +}; + +export default kilocodePlugin; diff --git a/extensions/kilocode/openclaw.plugin.json b/extensions/kilocode/openclaw.plugin.json new file mode 100644 index 00000000000..ec078c33ab7 --- /dev/null +++ b/extensions/kilocode/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "kilocode", + "providers": ["kilocode"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/kilocode/package.json b/extensions/kilocode/package.json new file mode 100644 index 00000000000..9ef4b7fe0c5 --- /dev/null +++ b/extensions/kilocode/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/kilocode-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Kilo Gateway provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/kimi-coding/index.ts b/extensions/kimi-coding/index.ts new file mode 100644 index 00000000000..d6e6e1d74a7 --- /dev/null +++ b/extensions/kimi-coding/index.ts @@ -0,0 +1,58 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { buildKimiCodingProvider } from "../../src/agents/models-config.providers.static.js"; +import { isRecord } from "../../src/utils.js"; + +const PROVIDER_ID = "kimi-coding"; + +const kimiCodingPlugin = { + id: PROVIDER_ID, + name: "Kimi Coding Provider", + description: "Bundled Kimi Coding provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Kimi Coding", + aliases: ["kimi-code"], + docsPath: "/providers/moonshot", + envVars: ["KIMI_API_KEY", "KIMICODE_API_KEY"], + auth: [], + 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 builtInProvider = buildKimiCodingProvider(); + const explicitBaseUrl = + typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : ""; + const explicitHeaders = isRecord(explicitProvider?.headers) + ? explicitProvider.headers + : undefined; + return { + provider: { + ...builtInProvider, + ...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}), + ...(explicitHeaders + ? { + headers: { + ...builtInProvider.headers, + ...explicitHeaders, + }, + } + : {}), + apiKey, + }, + }; + }, + }, + capabilities: { + preserveAnthropicThinkingSignatures: false, + }, + }); + }, +}; + +export default kimiCodingPlugin; diff --git a/extensions/kimi-coding/openclaw.plugin.json b/extensions/kimi-coding/openclaw.plugin.json new file mode 100644 index 00000000000..8874fb6501b --- /dev/null +++ b/extensions/kimi-coding/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "kimi-coding", + "providers": ["kimi-coding"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/kimi-coding/package.json b/extensions/kimi-coding/package.json new file mode 100644 index 00000000000..738dd1abd1f --- /dev/null +++ b/extensions/kimi-coding/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/kimi-coding-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Kimi Coding provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts new file mode 100644 index 00000000000..4076362404f --- /dev/null +++ b/extensions/minimax/index.ts @@ -0,0 +1,37 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { buildMinimaxProvider } from "../../src/agents/models-config.providers.static.js"; + +const PROVIDER_ID = "minimax"; + +const minimaxPlugin = { + id: PROVIDER_ID, + name: "MiniMax Provider", + description: "Bundled MiniMax provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "MiniMax", + docsPath: "/providers/minimax", + envVars: ["MINIMAX_API_KEY"], + auth: [], + catalog: { + order: "simple", + run: async (ctx) => { + const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + if (!apiKey) { + return null; + } + return { + provider: { + ...buildMinimaxProvider(), + apiKey, + }, + }; + }, + }, + }); + }, +}; + +export default minimaxPlugin; diff --git a/extensions/minimax/openclaw.plugin.json b/extensions/minimax/openclaw.plugin.json new file mode 100644 index 00000000000..01f3e5efbea --- /dev/null +++ b/extensions/minimax/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "minimax", + "providers": ["minimax"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/minimax/package.json b/extensions/minimax/package.json new file mode 100644 index 00000000000..6650cf1e456 --- /dev/null +++ b/extensions/minimax/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/minimax-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw MiniMax provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/modelstudio/index.ts b/extensions/modelstudio/index.ts new file mode 100644 index 00000000000..487f14498b1 --- /dev/null +++ b/extensions/modelstudio/index.ts @@ -0,0 +1,41 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { buildModelStudioProvider } from "../../src/agents/models-config.providers.static.js"; + +const PROVIDER_ID = "modelstudio"; + +const modelStudioPlugin = { + id: PROVIDER_ID, + name: "Model Studio Provider", + description: "Bundled Model Studio provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Model Studio", + docsPath: "/providers/models", + envVars: ["MODELSTUDIO_API_KEY"], + auth: [], + 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, + }, + }; + }, + }, + }); + }, +}; + +export default modelStudioPlugin; diff --git a/extensions/modelstudio/openclaw.plugin.json b/extensions/modelstudio/openclaw.plugin.json new file mode 100644 index 00000000000..1a8d9e71c75 --- /dev/null +++ b/extensions/modelstudio/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "modelstudio", + "providers": ["modelstudio"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/modelstudio/package.json b/extensions/modelstudio/package.json new file mode 100644 index 00000000000..631c87d53ca --- /dev/null +++ b/extensions/modelstudio/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/modelstudio-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Model Studio provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts new file mode 100644 index 00000000000..59176e42c15 --- /dev/null +++ b/extensions/moonshot/index.ts @@ -0,0 +1,52 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { buildMoonshotProvider } from "../../src/agents/models-config.providers.static.js"; +import { + createMoonshotThinkingWrapper, + resolveMoonshotThinkingType, +} from "../../src/agents/pi-embedded-runner/moonshot-stream-wrappers.js"; + +const PROVIDER_ID = "moonshot"; + +const moonshotPlugin = { + id: PROVIDER_ID, + name: "Moonshot Provider", + description: "Bundled Moonshot provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Moonshot", + docsPath: "/providers/moonshot", + envVars: ["MOONSHOT_API_KEY"], + auth: [], + 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, + }, + }; + }, + }, + wrapStreamFn: (ctx) => { + const thinkingType = resolveMoonshotThinkingType({ + configuredThinking: ctx.extraParams?.thinking, + thinkingLevel: ctx.thinkingLevel, + }); + return createMoonshotThinkingWrapper(ctx.streamFn, thinkingType); + }, + }); + }, +}; + +export default moonshotPlugin; diff --git a/extensions/moonshot/openclaw.plugin.json b/extensions/moonshot/openclaw.plugin.json new file mode 100644 index 00000000000..e02cb3d21c5 --- /dev/null +++ b/extensions/moonshot/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "moonshot", + "providers": ["moonshot"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/moonshot/package.json b/extensions/moonshot/package.json new file mode 100644 index 00000000000..a9dab300c74 --- /dev/null +++ b/extensions/moonshot/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/moonshot-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Moonshot provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/nvidia/index.ts b/extensions/nvidia/index.ts new file mode 100644 index 00000000000..afa83c4dff4 --- /dev/null +++ b/extensions/nvidia/index.ts @@ -0,0 +1,37 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { buildNvidiaProvider } from "../../src/agents/models-config.providers.static.js"; + +const PROVIDER_ID = "nvidia"; + +const nvidiaPlugin = { + id: PROVIDER_ID, + name: "NVIDIA Provider", + description: "Bundled NVIDIA provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "NVIDIA", + docsPath: "/providers/nvidia", + envVars: ["NVIDIA_API_KEY"], + auth: [], + catalog: { + order: "simple", + run: async (ctx) => { + const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + if (!apiKey) { + return null; + } + return { + provider: { + ...buildNvidiaProvider(), + apiKey, + }, + }; + }, + }, + }); + }, +}; + +export default nvidiaPlugin; diff --git a/extensions/nvidia/openclaw.plugin.json b/extensions/nvidia/openclaw.plugin.json new file mode 100644 index 00000000000..268bfa2dafd --- /dev/null +++ b/extensions/nvidia/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "nvidia", + "providers": ["nvidia"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/nvidia/package.json b/extensions/nvidia/package.json new file mode 100644 index 00000000000..2caee766789 --- /dev/null +++ b/extensions/nvidia/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/nvidia-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw NVIDIA provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/qianfan/index.ts b/extensions/qianfan/index.ts new file mode 100644 index 00000000000..1da228d3772 --- /dev/null +++ b/extensions/qianfan/index.ts @@ -0,0 +1,37 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { buildQianfanProvider } from "../../src/agents/models-config.providers.static.js"; + +const PROVIDER_ID = "qianfan"; + +const qianfanPlugin = { + id: PROVIDER_ID, + name: "Qianfan Provider", + description: "Bundled Qianfan provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Qianfan", + docsPath: "/providers/qianfan", + envVars: ["QIANFAN_API_KEY"], + auth: [], + catalog: { + order: "simple", + run: async (ctx) => { + const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + if (!apiKey) { + return null; + } + return { + provider: { + ...buildQianfanProvider(), + apiKey, + }, + }; + }, + }, + }); + }, +}; + +export default qianfanPlugin; diff --git a/extensions/qianfan/openclaw.plugin.json b/extensions/qianfan/openclaw.plugin.json new file mode 100644 index 00000000000..9bd75d78c4b --- /dev/null +++ b/extensions/qianfan/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "qianfan", + "providers": ["qianfan"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/qianfan/package.json b/extensions/qianfan/package.json new file mode 100644 index 00000000000..57b2177e6d8 --- /dev/null +++ b/extensions/qianfan/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/qianfan-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Qianfan provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/synthetic/index.ts b/extensions/synthetic/index.ts new file mode 100644 index 00000000000..c22dcc11f8b --- /dev/null +++ b/extensions/synthetic/index.ts @@ -0,0 +1,37 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { buildSyntheticProvider } from "../../src/agents/models-config.providers.static.js"; + +const PROVIDER_ID = "synthetic"; + +const syntheticPlugin = { + id: PROVIDER_ID, + name: "Synthetic Provider", + description: "Bundled Synthetic provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Synthetic", + docsPath: "/providers/synthetic", + envVars: ["SYNTHETIC_API_KEY"], + auth: [], + catalog: { + order: "simple", + run: async (ctx) => { + const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + if (!apiKey) { + return null; + } + return { + provider: { + ...buildSyntheticProvider(), + apiKey, + }, + }; + }, + }, + }); + }, +}; + +export default syntheticPlugin; diff --git a/extensions/synthetic/openclaw.plugin.json b/extensions/synthetic/openclaw.plugin.json new file mode 100644 index 00000000000..fab1326ca34 --- /dev/null +++ b/extensions/synthetic/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "synthetic", + "providers": ["synthetic"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/synthetic/package.json b/extensions/synthetic/package.json new file mode 100644 index 00000000000..ec471f1eadf --- /dev/null +++ b/extensions/synthetic/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/synthetic-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Synthetic provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/together/index.ts b/extensions/together/index.ts new file mode 100644 index 00000000000..5b1f6ced62f --- /dev/null +++ b/extensions/together/index.ts @@ -0,0 +1,37 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { buildTogetherProvider } from "../../src/agents/models-config.providers.static.js"; + +const PROVIDER_ID = "together"; + +const togetherPlugin = { + id: PROVIDER_ID, + name: "Together Provider", + description: "Bundled Together provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Together", + docsPath: "/providers/together", + envVars: ["TOGETHER_API_KEY"], + auth: [], + catalog: { + order: "simple", + run: async (ctx) => { + const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + if (!apiKey) { + return null; + } + return { + provider: { + ...buildTogetherProvider(), + apiKey, + }, + }; + }, + }, + }); + }, +}; + +export default togetherPlugin; diff --git a/extensions/together/openclaw.plugin.json b/extensions/together/openclaw.plugin.json new file mode 100644 index 00000000000..2a868251f34 --- /dev/null +++ b/extensions/together/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "together", + "providers": ["together"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/together/package.json b/extensions/together/package.json new file mode 100644 index 00000000000..982a0a03734 --- /dev/null +++ b/extensions/together/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/together-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Together provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/venice/index.ts b/extensions/venice/index.ts new file mode 100644 index 00000000000..75cd6adbaf1 --- /dev/null +++ b/extensions/venice/index.ts @@ -0,0 +1,37 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { buildVeniceProvider } from "../../src/agents/models-config.providers.discovery.js"; + +const PROVIDER_ID = "venice"; + +const venicePlugin = { + id: PROVIDER_ID, + name: "Venice Provider", + description: "Bundled Venice provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Venice", + docsPath: "/providers/venice", + envVars: ["VENICE_API_KEY"], + auth: [], + catalog: { + order: "simple", + run: async (ctx) => { + const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + if (!apiKey) { + return null; + } + return { + provider: { + ...(await buildVeniceProvider()), + apiKey, + }, + }; + }, + }, + }); + }, +}; + +export default venicePlugin; diff --git a/extensions/venice/openclaw.plugin.json b/extensions/venice/openclaw.plugin.json new file mode 100644 index 00000000000..6262595509e --- /dev/null +++ b/extensions/venice/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "venice", + "providers": ["venice"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/venice/package.json b/extensions/venice/package.json new file mode 100644 index 00000000000..1fa9b083088 --- /dev/null +++ b/extensions/venice/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/venice-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Venice provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/vercel-ai-gateway/index.ts b/extensions/vercel-ai-gateway/index.ts new file mode 100644 index 00000000000..c3098130f3e --- /dev/null +++ b/extensions/vercel-ai-gateway/index.ts @@ -0,0 +1,37 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { buildVercelAiGatewayProvider } from "../../src/agents/models-config.providers.discovery.js"; + +const PROVIDER_ID = "vercel-ai-gateway"; + +const vercelAiGatewayPlugin = { + id: PROVIDER_ID, + name: "Vercel AI Gateway Provider", + description: "Bundled Vercel AI Gateway provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Vercel AI Gateway", + docsPath: "/providers/vercel-ai-gateway", + envVars: ["AI_GATEWAY_API_KEY"], + auth: [], + catalog: { + order: "simple", + run: async (ctx) => { + const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + if (!apiKey) { + return null; + } + return { + provider: { + ...(await buildVercelAiGatewayProvider()), + apiKey, + }, + }; + }, + }, + }); + }, +}; + +export default vercelAiGatewayPlugin; diff --git a/extensions/vercel-ai-gateway/openclaw.plugin.json b/extensions/vercel-ai-gateway/openclaw.plugin.json new file mode 100644 index 00000000000..14f4a214605 --- /dev/null +++ b/extensions/vercel-ai-gateway/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "vercel-ai-gateway", + "providers": ["vercel-ai-gateway"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/vercel-ai-gateway/package.json b/extensions/vercel-ai-gateway/package.json new file mode 100644 index 00000000000..c81a82e40c0 --- /dev/null +++ b/extensions/vercel-ai-gateway/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/vercel-ai-gateway-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Vercel AI Gateway provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/volcengine/index.ts b/extensions/volcengine/index.ts new file mode 100644 index 00000000000..7d907b5f53e --- /dev/null +++ b/extensions/volcengine/index.ts @@ -0,0 +1,40 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { + buildDoubaoCodingProvider, + buildDoubaoProvider, +} from "../../src/agents/models-config.providers.static.js"; + +const PROVIDER_ID = "volcengine"; + +const volcenginePlugin = { + id: PROVIDER_ID, + name: "Volcengine Provider", + description: "Bundled Volcengine provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Volcengine", + docsPath: "/concepts/model-providers#volcano-engine-doubao", + envVars: ["VOLCANO_ENGINE_API_KEY"], + auth: [], + catalog: { + order: "paired", + run: async (ctx) => { + const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + if (!apiKey) { + return null; + } + return { + providers: { + volcengine: { ...buildDoubaoProvider(), apiKey }, + "volcengine-plan": { ...buildDoubaoCodingProvider(), apiKey }, + }, + }; + }, + }, + }); + }, +}; + +export default volcenginePlugin; diff --git a/extensions/volcengine/openclaw.plugin.json b/extensions/volcengine/openclaw.plugin.json new file mode 100644 index 00000000000..0773577aef9 --- /dev/null +++ b/extensions/volcengine/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "volcengine", + "providers": ["volcengine", "volcengine-plan"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/volcengine/package.json b/extensions/volcengine/package.json new file mode 100644 index 00000000000..5e65f3522ae --- /dev/null +++ b/extensions/volcengine/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/volcengine-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Volcengine provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/xiaomi/index.ts b/extensions/xiaomi/index.ts new file mode 100644 index 00000000000..847d7836ecc --- /dev/null +++ b/extensions/xiaomi/index.ts @@ -0,0 +1,37 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { buildXiaomiProvider } from "../../src/agents/models-config.providers.static.js"; + +const PROVIDER_ID = "xiaomi"; + +const xiaomiPlugin = { + id: PROVIDER_ID, + name: "Xiaomi Provider", + description: "Bundled Xiaomi provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Xiaomi", + docsPath: "/providers/xiaomi", + envVars: ["XIAOMI_API_KEY"], + auth: [], + catalog: { + order: "simple", + run: async (ctx) => { + const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + if (!apiKey) { + return null; + } + return { + provider: { + ...buildXiaomiProvider(), + apiKey, + }, + }; + }, + }, + }); + }, +}; + +export default xiaomiPlugin; diff --git a/extensions/xiaomi/openclaw.plugin.json b/extensions/xiaomi/openclaw.plugin.json new file mode 100644 index 00000000000..78c758c6571 --- /dev/null +++ b/extensions/xiaomi/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "xiaomi", + "providers": ["xiaomi"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/xiaomi/package.json b/extensions/xiaomi/package.json new file mode 100644 index 00000000000..dc89cc57160 --- /dev/null +++ b/extensions/xiaomi/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/xiaomi-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Xiaomi provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} From 9eed6e674bf6c68823ec4ecb43e712fcf7a4ace1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 16:09:29 -0700 Subject: [PATCH 038/943] fix(plugins): restore provider compatibility fallbacks --- extensions/ollama/index.ts | 3 +- ...ig.providers.cloudflare-ai-gateway.test.ts | 83 +++++++++++++++++++ ....providers.plugin-allowlist-compat.test.ts | 53 ++++++++++++ src/agents/pi-embedded-runner/cache-ttl.ts | 4 +- .../extra-params.kilocode.test.ts | 72 +++++++++++++++- src/plugins/provider-discovery.ts | 5 +- src/plugins/provider-runtime.test.ts | 6 ++ src/plugins/provider-runtime.ts | 7 +- src/plugins/providers.test.ts | 21 +++++ src/plugins/providers.ts | 66 ++++++++++++++- 10 files changed, 308 insertions(+), 12 deletions(-) create mode 100644 src/agents/models-config.providers.plugin-allowlist-compat.test.ts diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 6ba28a3af7c..c0b325e5a64 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -11,6 +11,7 @@ import { type ProviderAuthResult, type ProviderDiscoveryContext, } from "openclaw/plugin-sdk/core"; +import { resolveOllamaApiBase } from "../../src/agents/models-config.providers.discovery.js"; const PROVIDER_ID = "ollama"; const DEFAULT_API_KEY = "ollama-local"; @@ -72,7 +73,7 @@ const ollamaPlugin = { ...explicit, baseUrl: typeof explicit.baseUrl === "string" && explicit.baseUrl.trim() - ? explicit.baseUrl.trim().replace(/\/+$/, "") + ? resolveOllamaApiBase(explicit.baseUrl) : OLLAMA_DEFAULT_BASE_URL, api: explicit.api ?? "ollama", apiKey: ollamaKey ?? explicit.apiKey ?? DEFAULT_API_KEY, diff --git a/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts b/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts index dad90c740d2..c6de651e811 100644 --- a/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts +++ b/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { captureEnv } from "../test-utils/env.js"; +import { resolveCloudflareAiGatewayBaseUrl } from "./cloudflare-ai-gateway.js"; import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; @@ -73,4 +74,86 @@ describe("cloudflare-ai-gateway profile provenance", () => { const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); }); + + it("keeps Cloudflare gateway metadata and apiKey from the same auth profile", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "cloudflare-ai-gateway:key-only": { + type: "api_key", + provider: "cloudflare-ai-gateway", + key: "sk-first", + }, + "cloudflare-ai-gateway:gateway": { + type: "api_key", + provider: "cloudflare-ai-gateway", + key: "sk-second", + metadata: { + accountId: "acct_456", + gatewayId: "gateway_789", + }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe("sk-second"); + expect(providers?.["cloudflare-ai-gateway"]?.baseUrl).toBe( + resolveCloudflareAiGatewayBaseUrl({ + accountId: "acct_456", + gatewayId: "gateway_789", + }), + ); + }); + + it("prefers the runtime env marker over stored profile secrets", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["CLOUDFLARE_AI_GATEWAY_API_KEY"]); + process.env.CLOUDFLARE_AI_GATEWAY_API_KEY = "rotated-secret"; // pragma: allowlist secret + + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "cloudflare-ai-gateway:default": { + type: "api_key", + provider: "cloudflare-ai-gateway", + key: "stale-stored-secret", + metadata: { + accountId: "acct_123", + gatewayId: "gateway_456", + }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + try { + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe("CLOUDFLARE_AI_GATEWAY_API_KEY"); + expect(providers?.["cloudflare-ai-gateway"]?.baseUrl).toBe( + resolveCloudflareAiGatewayBaseUrl({ + accountId: "acct_123", + gatewayId: "gateway_456", + }), + ); + } finally { + envSnapshot.restore(); + } + }); }); diff --git a/src/agents/models-config.providers.plugin-allowlist-compat.test.ts b/src/agents/models-config.providers.plugin-allowlist-compat.test.ts new file mode 100644 index 00000000000..594ebce3e2c --- /dev/null +++ b/src/agents/models-config.providers.plugin-allowlist-compat.test.ts @@ -0,0 +1,53 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; + +describe("implicit provider plugin allowlist compatibility", () => { + it("keeps bundled implicit providers discoverable when plugins.allow is set", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["KILOCODE_API_KEY", "MOONSHOT_API_KEY"]); + process.env.KILOCODE_API_KEY = "test-kilo-key"; // pragma: allowlist secret + process.env.MOONSHOT_API_KEY = "test-moonshot-key"; // pragma: allowlist secret + + try { + const providers = await resolveImplicitProvidersForTest({ + agentDir, + config: { + plugins: { + allow: ["openrouter"], + }, + }, + }); + expect(providers?.kilocode).toBeDefined(); + expect(providers?.moonshot).toBeDefined(); + } finally { + envSnapshot.restore(); + } + }); + + it("still honors explicit plugin denies over compat allowlist injection", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["KILOCODE_API_KEY", "MOONSHOT_API_KEY"]); + process.env.KILOCODE_API_KEY = "test-kilo-key"; // pragma: allowlist secret + process.env.MOONSHOT_API_KEY = "test-moonshot-key"; // pragma: allowlist secret + + try { + const providers = await resolveImplicitProvidersForTest({ + agentDir, + config: { + plugins: { + allow: ["openrouter"], + deny: ["kilocode"], + }, + }, + }); + expect(providers?.kilocode).toBeUndefined(); + expect(providers?.moonshot).toBeDefined(); + } finally { + envSnapshot.restore(); + } + }); +}); diff --git a/src/agents/pi-embedded-runner/cache-ttl.ts b/src/agents/pi-embedded-runner/cache-ttl.ts index e971f564edd..02075cd78cf 100644 --- a/src/agents/pi-embedded-runner/cache-ttl.ts +++ b/src/agents/pi-embedded-runner/cache-ttl.ts @@ -25,10 +25,10 @@ export function isCacheTtlEligibleProvider(provider: string, modelId: string): b if (pluginEligibility !== undefined) { return pluginEligibility; } - if (CACHE_TTL_NATIVE_PROVIDERS.has(normalizedProvider)) { + if (normalizedProvider === "kilocode" && normalizedModelId.startsWith("anthropic/")) { return true; } - if (normalizedProvider === "kilocode" && normalizedModelId.startsWith("anthropic/")) { + if (CACHE_TTL_NATIVE_PROVIDERS.has(normalizedProvider)) { return true; } return false; diff --git a/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts b/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts index 35a6cefcbd4..c4e81d2d804 100644 --- a/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts @@ -2,6 +2,7 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import type { Context, Model } from "@mariozechner/pi-ai"; import { createAssistantMessageEventStream } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; import { captureEnv } from "../../test-utils/env.js"; import { applyExtraParamsToAgent } from "./extra-params.js"; @@ -10,10 +11,21 @@ type CapturedCall = { payload?: Record; }; +const TEST_CFG = { + plugins: { + entries: { + kilocode: { + enabled: true, + }, + }, + }, +} satisfies OpenClawConfig; + function applyAndCapture(params: { provider: string; modelId: string; callerHeaders?: Record; + cfg?: OpenClawConfig; }): CapturedCall { const captured: CapturedCall = {}; @@ -24,7 +36,7 @@ function applyAndCapture(params: { }; const agent = { streamFn: baseStreamFn }; - applyExtraParamsToAgent(agent, undefined, params.provider, params.modelId); + applyExtraParamsToAgent(agent, params.cfg ?? TEST_CFG, params.provider, params.modelId); const model = { api: "openai-completions", @@ -81,6 +93,22 @@ describe("extra-params: Kilocode wrapper", () => { expect(headers?.["X-KILOCODE-FEATURE"]).toBe("openclaw"); }); + it("keeps Kilocode runtime wrapping under restrictive plugins.allow", () => { + delete process.env.KILOCODE_FEATURE; + + const { headers } = applyAndCapture({ + provider: "kilocode", + modelId: "anthropic/claude-sonnet-4", + cfg: { + plugins: { + allow: ["openrouter"], + }, + }, + }); + + expect(headers?.["X-KILOCODE-FEATURE"]).toBe("openclaw"); + }); + it("does not inject header for non-kilocode providers", () => { const { headers } = applyAndCapture({ provider: "openrouter", @@ -104,7 +132,7 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => { const agent = { streamFn: baseStreamFn }; // Pass thinking level explicitly (6th parameter) to trigger reasoning injection - applyExtraParamsToAgent(agent, undefined, "kilocode", "kilo/auto", undefined, "high"); + applyExtraParamsToAgent(agent, TEST_CFG, "kilocode", "kilo/auto", undefined, "high"); const model = { api: "openai-completions", @@ -133,7 +161,7 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => { applyExtraParamsToAgent( agent, - undefined, + TEST_CFG, "kilocode", "anthropic/claude-sonnet-4", undefined, @@ -153,6 +181,42 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => { expect(capturedPayload?.reasoning).toEqual({ effort: "high" }); }); + it("still normalizes reasoning for Kilocode under restrictive plugins.allow", () => { + let capturedPayload: Record | undefined; + + const baseStreamFn: StreamFn = (model, _context, options) => { + const payload: Record = {}; + options?.onPayload?.(payload, model); + capturedPayload = payload; + return createAssistantMessageEventStream(); + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent( + agent, + { + plugins: { + allow: ["openrouter"], + }, + }, + "kilocode", + "anthropic/claude-sonnet-4", + undefined, + "high", + ); + + const model = { + api: "openai-completions", + provider: "kilocode", + id: "anthropic/claude-sonnet-4", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + + void agent.streamFn?.(model, context, {}); + + expect(capturedPayload?.reasoning).toEqual({ effort: "high" }); + }); + it("does not inject reasoning.effort for x-ai models", () => { let capturedPayload: Record | undefined; @@ -164,7 +228,7 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => { }; const agent = { streamFn: baseStreamFn }; - applyExtraParamsToAgent(agent, undefined, "kilocode", "x-ai/grok-3", undefined, "high"); + applyExtraParamsToAgent(agent, TEST_CFG, "kilocode", "x-ai/grok-3", undefined, "high"); const model = { api: "openai-completions", diff --git a/src/plugins/provider-discovery.ts b/src/plugins/provider-discovery.ts index ccecd889fa3..e249bf6e45a 100644 --- a/src/plugins/provider-discovery.ts +++ b/src/plugins/provider-discovery.ts @@ -15,7 +15,10 @@ export function resolvePluginDiscoveryProviders(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }): ProviderPlugin[] { - return resolvePluginProviders(params).filter((provider) => resolveProviderCatalogHook(provider)); + return resolvePluginProviders({ + ...params, + bundledProviderAllowlistCompat: true, + }).filter((provider) => resolveProviderCatalogHook(provider)); } export function groupPluginDiscoveryProvidersByOrder( diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 9db3ef3e002..723c5344bb4 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -51,6 +51,12 @@ describe("provider-runtime", () => { const plugin = resolveProviderRuntimePlugin({ provider: "Open Router" }); expect(plugin?.id).toBe("openrouter"); + expect(resolvePluginProvidersMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "Open Router", + bundledProviderAllowlistCompat: true, + }), + ); }); it("dispatches runtime hooks for the matched provider", async () => { diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index ca44f33a8ba..a96cc7a0569 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -29,9 +29,10 @@ export function resolveProviderRuntimePlugin(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }): ProviderPlugin | undefined { - return resolvePluginProviders(params).find((plugin) => - matchesProviderId(plugin, params.provider), - ); + return resolvePluginProviders({ + ...params, + bundledProviderAllowlistCompat: true, + }).find((plugin) => matchesProviderId(plugin, params.provider)); } export function runProviderDynamicModel(params: { diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index 26c70df090a..7df6432b4c3 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -31,4 +31,25 @@ describe("resolvePluginProviders", () => { }), ); }); + + it("can augment restrictive allowlists for bundled provider compatibility", () => { + resolvePluginProviders({ + config: { + plugins: { + allow: ["openrouter"], + }, + }, + bundledProviderAllowlistCompat: true, + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + plugins: expect.objectContaining({ + allow: expect.arrayContaining(["openrouter", "kilocode", "moonshot"]), + }), + }), + }), + ); + }); }); diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 4847a61935b..dda000e2641 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -4,15 +4,79 @@ import { createPluginLoaderLogger } from "./logger.js"; import type { ProviderPlugin } from "./types.js"; const log = createSubsystemLogger("plugins"); +const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [ + "byteplus", + "cloudflare-ai-gateway", + "copilot-proxy", + "github-copilot", + "google-gemini-cli-auth", + "huggingface", + "kilocode", + "kimi-coding", + "minimax", + "minimax-portal-auth", + "modelstudio", + "moonshot", + "nvidia", + "ollama", + "openai-codex", + "openrouter", + "qianfan", + "qwen-portal-auth", + "sglang", + "synthetic", + "together", + "venice", + "vercel-ai-gateway", + "volcengine", + "vllm", + "xiaomi", +] as const; + +function withBundledProviderAllowlistCompat( + config: PluginLoadOptions["config"], +): PluginLoadOptions["config"] { + const allow = config?.plugins?.allow; + if (!Array.isArray(allow) || allow.length === 0) { + return config; + } + + const allowSet = new Set(allow.map((entry) => entry.trim()).filter(Boolean)); + let changed = false; + for (const pluginId of BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS) { + if (!allowSet.has(pluginId)) { + allowSet.add(pluginId); + changed = true; + } + } + + if (!changed) { + return config; + } + + return { + ...config, + plugins: { + ...config?.plugins, + // Backward compat: bundled implicit providers historically stayed + // available even when operators kept a restrictive plugin allowlist. + allow: [...allowSet], + }, + }; +} export function resolvePluginProviders(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; /** Use an explicit env when plugin roots should resolve independently from process.env. */ env?: PluginLoadOptions["env"]; + bundledProviderAllowlistCompat?: boolean; }): ProviderPlugin[] { + const config = params.bundledProviderAllowlistCompat + ? withBundledProviderAllowlistCompat(params.config) + : params.config; const registry = loadOpenClawPlugins({ - config: params.config, + config, workspaceDir: params.workspaceDir, env: params.env, logger: createPluginLoaderLogger(log), From 963237a18f5c8073f828767636b65c3f4ca9a68b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 16:09:13 -0700 Subject: [PATCH 039/943] Changelog: note plugin agent integrations --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index af21fcd7c45..ca1d5cf8998 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs. - Plugins/providers: move OpenRouter, GitHub Copilot, and OpenAI Codex provider/runtime logic into bundled plugins, including dynamic model fallback, runtime auth exchange, stream wrappers, capability hints, and cache-TTL policy. - Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized. +- Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc. ### Fixes From 74c762beb0efab0df6037638f781327fbdb23991 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 16:17:24 -0700 Subject: [PATCH 040/943] refactor: decouple channel setup discovery --- src/auto-reply/reply/route-reply.test.ts | 6 + src/channels/plugins/setup-registry.ts | 80 ++++++++++++ src/commands/onboard-channels.e2e.test.ts | 12 +- src/commands/onboard-channels.ts | 64 +++++----- src/commands/onboarding/registry.ts | 44 +++---- src/gateway/server-plugins.test.ts | 1 + ...server.agent.gateway-server-agent.mocks.ts | 1 + src/gateway/test-helpers.mocks.ts | 1 + src/plugins/loader.ts | 54 ++++++--- src/plugins/registry.ts | 114 +++++++++++++----- src/test-utils/channel-plugins.ts | 6 + 11 files changed, 270 insertions(+), 113 deletions(-) create mode 100644 src/channels/plugins/setup-registry.ts diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 776a2374fbc..ed507607c83 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -81,6 +81,12 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => typedHooks: [], commands: [], channels, + channelSetups: channels.map((entry) => ({ + pluginId: entry.pluginId, + plugin: entry.plugin, + source: entry.source, + enabled: true, + })), providers: [], gatewayHandlers: {}, httpRoutes: [], diff --git a/src/channels/plugins/setup-registry.ts b/src/channels/plugins/setup-registry.ts new file mode 100644 index 00000000000..493b14351cc --- /dev/null +++ b/src/channels/plugins/setup-registry.ts @@ -0,0 +1,80 @@ +import { + getActivePluginRegistryVersion, + requireActivePluginRegistry, +} from "../../plugins/runtime.js"; +import { CHAT_CHANNEL_ORDER, type ChatChannelId } from "../registry.js"; +import type { ChannelId, ChannelPlugin } from "./types.js"; + +type CachedChannelSetupPlugins = { + registryVersion: number; + sorted: ChannelPlugin[]; + byId: Map; +}; + +const EMPTY_CHANNEL_SETUP_CACHE: CachedChannelSetupPlugins = { + registryVersion: -1, + sorted: [], + byId: new Map(), +}; + +let cachedChannelSetupPlugins = EMPTY_CHANNEL_SETUP_CACHE; + +function dedupeSetupPlugins(plugins: ChannelPlugin[]): ChannelPlugin[] { + const seen = new Set(); + const resolved: ChannelPlugin[] = []; + for (const plugin of plugins) { + const id = String(plugin.id).trim(); + if (!id || seen.has(id)) { + continue; + } + seen.add(id); + resolved.push(plugin); + } + return resolved; +} + +function resolveCachedChannelSetupPlugins(): CachedChannelSetupPlugins { + const registry = requireActivePluginRegistry(); + const registryVersion = getActivePluginRegistryVersion(); + const cached = cachedChannelSetupPlugins; + if (cached.registryVersion === registryVersion) { + return cached; + } + + const sorted = dedupeSetupPlugins( + (registry.channelSetups ?? []).map((entry) => entry.plugin), + ).toSorted((a, b) => { + const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId); + const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId); + const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA); + const orderB = b.meta.order ?? (indexB === -1 ? 999 : indexB); + if (orderA !== orderB) { + return orderA - orderB; + } + return a.id.localeCompare(b.id); + }); + const byId = new Map(); + for (const plugin of sorted) { + byId.set(plugin.id, plugin); + } + + const next: CachedChannelSetupPlugins = { + registryVersion, + sorted, + byId, + }; + cachedChannelSetupPlugins = next; + return next; +} + +export function listChannelSetupPlugins(): ChannelPlugin[] { + return resolveCachedChannelSetupPlugins().sorted.slice(); +} + +export function getChannelSetupPlugin(id: ChannelId): ChannelPlugin | undefined { + const resolvedId = String(id).trim(); + if (!resolvedId) { + return undefined; + } + return resolveCachedChannelSetupPlugins().byId.get(resolvedId); +} diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index 88606bcc3cc..b25bf35db78 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -307,12 +307,9 @@ describe("setupChannels", () => { it("adds disabled hint to channel selection when a channel is disabled", async () => { let selectionCount = 0; - const select = vi.fn(async ({ message, options }: { message: string; options: unknown[] }) => { + const select = vi.fn(async ({ message }: { message: string; options: unknown[] }) => { if (message === "Select a channel") { selectionCount += 1; - const opts = options as Array<{ value: string; hint?: string }>; - const telegram = opts.find((opt) => opt.value === "telegram"); - expect(telegram?.hint).toContain("disabled"); return selectionCount === 1 ? "telegram" : "__done__"; } if (message.includes("already configured")) { @@ -332,6 +329,13 @@ describe("setupChannels", () => { await runSetupChannels(createTelegramCfg("token", false), prompter); expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" })); + const channelSelectCall = select.mock.calls.find( + ([params]) => (params as { message?: string }).message === "Select a channel", + ); + const telegramOption = ( + channelSelectCall?.[0] as { options?: Array<{ value: string; hint?: string }> } | undefined + )?.options?.find((opt) => opt.value === "telegram"); + expect(telegramOption?.hint).toContain("disabled"); expect(multiselect).not.toHaveBeenCalled(); }); diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 6e79379e1f1..ca4b090ce5a 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -1,7 +1,10 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; -import { listChannelPlugins, getChannelPlugin } from "../channels/plugins/index.js"; +import { + getChannelSetupPlugin, + listChannelSetupPlugins, +} from "../channels/plugins/setup-registry.js"; import type { ChannelMeta } from "../channels/plugins/types.js"; import { formatChannelPrimerLine, @@ -37,7 +40,7 @@ import type { type ConfiguredChannelAction = "update" | "disable" | "delete" | "skip"; type ChannelStatusSummary = { - installedPlugins: ReturnType; + installedPlugins: ReturnType; catalogEntries: ReturnType; statusByChannel: Map; statusLines: string[]; @@ -90,7 +93,7 @@ async function promptRemovalAccountId(params: { channel: ChannelChoice; }): Promise { const { cfg, prompter, label, channel } = params; - const plugin = getChannelPlugin(channel); + const plugin = getChannelSetupPlugin(channel); if (!plugin) { return DEFAULT_ACCOUNT_ID; } @@ -115,7 +118,7 @@ async function collectChannelStatus(params: { options?: SetupChannelsOptions; accountOverrides: Partial>; }): Promise { - const installedPlugins = listChannelPlugins(); + const installedPlugins = listChannelSetupPlugins(); const installedIds = new Set(installedPlugins.map((plugin) => plugin.id)); const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); const catalogEntries = listChannelPluginCatalogEntries({ workspaceDir }).filter( @@ -297,6 +300,13 @@ export async function setupChannels( options?: SetupChannelsOptions, ): Promise { let next = cfg; + if (listChannelOnboardingAdapters().length === 0) { + reloadOnboardingPluginRegistry({ + cfg: next, + runtime, + workspaceDir: resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next)), + }); + } const forceAllowFromChannels = new Set(options?.forceAllowFromChannels ?? []); const accountOverrides: Partial> = { ...options?.accountIds, @@ -366,7 +376,15 @@ export async function setupChannels( }; const resolveDisabledHint = (channel: ChannelChoice): string | undefined => { - const plugin = getChannelPlugin(channel); + const plugin = getChannelSetupPlugin(channel); + if ( + typeof (next.channels as Record | undefined)?.[channel] + ?.enabled === "boolean" + ) { + return (next.channels as Record)[channel]?.enabled === false + ? "disabled" + : undefined; + } if (!plugin) { if (next.plugins?.entries?.[channel]?.enabled === false) { return "plugin disabled"; @@ -383,11 +401,6 @@ export async function setupChannels( enabled = plugin.config.isEnabled(account, next); } else if (typeof (account as { enabled?: boolean })?.enabled === "boolean") { enabled = (account as { enabled?: boolean }).enabled; - } else if ( - typeof (next.channels as Record | undefined)?.[channel] - ?.enabled === "boolean" - ) { - enabled = (next.channels as Record)[channel]?.enabled; } return enabled === false ? "disabled" : undefined; }; @@ -411,7 +424,7 @@ export async function setupChannels( const getChannelEntries = () => { const core = listChatChannels(); - const installed = listChannelPlugins(); + const installed = listChannelSetupPlugins(); const installedIds = new Set(installed.map((plugin) => plugin.id)); const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next)); const catalog = listChannelPluginCatalogEntries({ workspaceDir }).filter( @@ -449,10 +462,7 @@ export async function setupChannels( statusByChannel.set(channel, status); }; - const ensureBundledPluginEnabled = async (channel: ChannelChoice): Promise => { - if (getChannelPlugin(channel)) { - return true; - } + const enableBundledPluginForSetup = async (channel: ChannelChoice): Promise => { const result = enablePluginInConfig(next, channel); next = result.config; if (!result.enabled) { @@ -468,24 +478,6 @@ export async function setupChannels( runtime, workspaceDir, }); - if (!getChannelPlugin(channel)) { - // Some installs/environments can fail to populate the plugin registry during onboarding, - // even for built-in channels. If the channel supports onboarding, proceed with config - // so setup isn't blocked; the gateway can still load plugins on startup. - const adapter = getChannelOnboardingAdapter(channel); - if (adapter) { - await prompter.note( - `${channel} plugin not available (continuing with onboarding). If the channel still doesn't work after setup, run \`${formatCliCommand( - "openclaw plugins list", - )}\` and \`${formatCliCommand("openclaw plugins enable " + channel)}\`, then restart the gateway.`, - "Channel setup", - ); - await refreshStatus(channel); - return true; - } - await prompter.note(`${channel} plugin not available.`, "Channel setup"); - return false; - } await refreshStatus(channel); return true; }; @@ -529,7 +521,7 @@ export async function setupChannels( }; const handleConfiguredChannel = async (channel: ChannelChoice, label: string) => { - const plugin = getChannelPlugin(channel); + const plugin = getChannelSetupPlugin(channel); const adapter = getChannelOnboardingAdapter(channel); if (adapter?.configureWhenConfigured) { const custom = await adapter.configureWhenConfigured({ @@ -642,13 +634,13 @@ export async function setupChannels( }); await refreshStatus(channel); } else { - const enabled = await ensureBundledPluginEnabled(channel); + const enabled = await enableBundledPluginForSetup(channel); if (!enabled) { return; } } - const plugin = getChannelPlugin(channel); + const plugin = getChannelSetupPlugin(channel); const adapter = getChannelOnboardingAdapter(channel); const label = plugin?.meta.label ?? catalogEntry?.meta.label ?? channel; const status = statusByChannel.get(channel); diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts index cd660350911..3f7bea2da19 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/onboarding/registry.ts @@ -1,34 +1,26 @@ -import { discordOnboardingAdapter } from "../../../extensions/discord/src/onboarding.js"; -import { imessageOnboardingAdapter } from "../../../extensions/imessage/src/onboarding.js"; -import { signalOnboardingAdapter } from "../../../extensions/signal/src/onboarding.js"; -import { slackOnboardingAdapter } from "../../../extensions/slack/src/onboarding.js"; -import { telegramOnboardingAdapter } from "../../../extensions/telegram/src/onboarding.js"; -import { whatsappOnboardingAdapter } from "../../../extensions/whatsapp/src/onboarding.js"; -import { listChannelPlugins } from "../../channels/plugins/index.js"; +import { listChannelSetupPlugins } from "../../channels/plugins/setup-registry.js"; import type { ChannelChoice } from "../onboard-types.js"; import type { ChannelOnboardingAdapter } from "./types.js"; -const BUILTIN_ONBOARDING_ADAPTERS: ChannelOnboardingAdapter[] = [ - telegramOnboardingAdapter, - whatsappOnboardingAdapter, - discordOnboardingAdapter, - slackOnboardingAdapter, - signalOnboardingAdapter, - imessageOnboardingAdapter, -]; +function resolveChannelOnboardingAdapter( + plugin: (typeof listChannelSetupPlugins)[number], +): ChannelOnboardingAdapter | undefined { + if (plugin.onboarding) { + return plugin.onboarding; + } + return undefined; +} const CHANNEL_ONBOARDING_ADAPTERS = () => { - const fromRegistry = listChannelPlugins() - .map((plugin) => (plugin.onboarding ? ([plugin.id, plugin.onboarding] as const) : null)) - .filter((entry): entry is readonly [ChannelChoice, ChannelOnboardingAdapter] => Boolean(entry)); - - // Fall back to built-in adapters to keep onboarding working even when the plugin registry - // fails to populate (see #25545). - const fromBuiltins = BUILTIN_ONBOARDING_ADAPTERS.map( - (adapter) => [adapter.channel, adapter] as const, - ); - - return new Map([...fromBuiltins, ...fromRegistry]); + const adapters = new Map(); + for (const plugin of listChannelSetupPlugins()) { + const adapter = resolveChannelOnboardingAdapter(plugin); + if (!adapter) { + continue; + } + adapters.set(plugin.id, adapter); + } + return adapters; }; export function getChannelOnboardingAdapter( diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 38f13cf6ac3..560392499c1 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -26,6 +26,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ hooks: [], typedHooks: [], channels: [], + channelSetups: [], commands: [], providers: [], gatewayHandlers: {}, diff --git a/src/gateway/server.agent.gateway-server-agent.mocks.ts b/src/gateway/server.agent.gateway-server-agent.mocks.ts index c3a33eca9ad..0e1f779ef4f 100644 --- a/src/gateway/server.agent.gateway-server-agent.mocks.ts +++ b/src/gateway/server.agent.gateway-server-agent.mocks.ts @@ -9,6 +9,7 @@ export const registryState: { registry: PluginRegistry } = { hooks: [], typedHooks: [], channels: [], + channelSetups: [], providers: [], gatewayHandlers: {}, httpHandlers: [], diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index c8032527294..17868ae0bca 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -144,6 +144,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({ plugin: createStubChannelPlugin({ id: "bluebubbles", label: "BlueBubbles" }), }, ], + channelSetups: [], providers: [], gatewayHandlers: {}, httpRoutes: [], diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 319b0ae90d7..b9ebc7f2a1e 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -847,13 +847,23 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi }); }; - if (!enableState.enabled) { + const registrationMode = enableState.enabled + ? "full" + : !validateOnly && manifestRecord.channels.length > 0 + ? "setup-only" + : null; + + if (!registrationMode) { record.status = "disabled"; record.error = enableState.reason; registry.plugins.push(record); seenIds.set(pluginId, candidate.origin); continue; } + if (!enableState.enabled) { + record.status = "disabled"; + record.error = enableState.reason; + } if (record.format === "bundle") { const unsupportedCapabilities = (record.bundleCapabilities ?? []).filter( @@ -878,10 +888,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi seenIds.set(pluginId, candidate.origin); continue; } - // Fast-path bundled memory plugins that are guaranteed disabled by slot policy. // This avoids opening/importing heavy memory plugin modules that will never register. - if (candidate.origin === "bundled" && manifestRecord.kind === "memory") { + if ( + registrationMode === "full" && + candidate.origin === "bundled" && + manifestRecord.kind === "memory" + ) { const earlyMemoryDecision = resolveMemorySlotDecision({ id: record.id, kind: "memory", @@ -966,24 +979,26 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi memorySlotMatched = true; } - const memoryDecision = resolveMemorySlotDecision({ - id: record.id, - kind: record.kind, - slot: memorySlot, - selectedId: selectedMemoryPluginId, - }); + if (registrationMode === "full") { + const memoryDecision = resolveMemorySlotDecision({ + id: record.id, + kind: record.kind, + slot: memorySlot, + selectedId: selectedMemoryPluginId, + }); - if (!memoryDecision.enabled) { - record.enabled = false; - record.status = "disabled"; - record.error = memoryDecision.reason; - registry.plugins.push(record); - seenIds.set(pluginId, candidate.origin); - continue; - } + if (!memoryDecision.enabled) { + record.enabled = false; + record.status = "disabled"; + record.error = memoryDecision.reason; + registry.plugins.push(record); + seenIds.set(pluginId, candidate.origin); + continue; + } - if (memoryDecision.selected && record.kind === "memory") { - selectedMemoryPluginId = record.id; + if (memoryDecision.selected && record.kind === "memory") { + selectedMemoryPluginId = record.id; + } } const validatedConfig = validatePluginConfig({ @@ -1014,6 +1029,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi config: cfg, pluginConfig: validatedConfig.value, hookPolicy: entry?.hooks, + registrationMode, }); try { diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index d754d928f15..4b28c277e05 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -85,6 +85,15 @@ export type PluginChannelRegistration = { rootDir?: string; }; +export type PluginChannelSetupRegistration = { + pluginId: string; + pluginName?: string; + plugin: ChannelPlugin; + source: string; + enabled: boolean; + rootDir?: string; +}; + export type PluginProviderRegistration = { pluginId: string; pluginName?: string; @@ -154,6 +163,7 @@ export type PluginRegistry = { hooks: PluginHookRegistration[]; typedHooks: TypedPluginHookRegistration[]; channels: PluginChannelRegistration[]; + channelSetups: PluginChannelSetupRegistration[]; providers: PluginProviderRegistration[]; gatewayHandlers: GatewayRequestHandlers; httpRoutes: PluginHttpRouteRegistration[]; @@ -173,6 +183,8 @@ type PluginTypedHookPolicy = { allowPromptInjection?: boolean; }; +type PluginRegistrationMode = "full" | "setup-only"; + const constrainLegacyPromptInjectionHook = ( handler: PluginHookHandlerMap["before_agent_start"], ): PluginHookHandlerMap["before_agent_start"] => { @@ -194,6 +206,7 @@ export function createEmptyPluginRegistry(): PluginRegistry { hooks: [], typedHooks: [], channels: [], + channelSetups: [], providers: [], gatewayHandlers: {}, httpRoutes: [], @@ -436,6 +449,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { const registerChannel = ( record: PluginRecord, registration: OpenClawPluginChannelRegistration | ChannelPlugin, + mode: PluginRegistrationMode = "full", ) => { const normalized = typeof (registration as OpenClawPluginChannelRegistration).plugin === "object" @@ -452,17 +466,38 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); return; } - const existing = registry.channels.find((entry) => entry.plugin.id === id); - if (existing) { + const existingRuntime = registry.channels.find((entry) => entry.plugin.id === id); + if (mode === "full" && existingRuntime) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, - message: `channel already registered: ${id} (${existing.pluginId})`, + message: `channel already registered: ${id} (${existingRuntime.pluginId})`, + }); + return; + } + const existingSetup = registry.channelSetups.find((entry) => entry.plugin.id === id); + if (existingSetup) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `channel setup already registered: ${id} (${existingSetup.pluginId})`, }); return; } record.channelIds.push(id); + registry.channelSetups.push({ + pluginId: record.id, + pluginName: record.name, + plugin, + source: record.source, + enabled: record.enabled, + rootDir: record.rootDir, + }); + if (mode === "setup-only") { + return; + } registry.channels.push({ pluginId: record.id, pluginName: record.name, @@ -667,8 +702,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { config: OpenClawPluginApi["config"]; pluginConfig?: Record; hookPolicy?: PluginTypedHookPolicy; + registrationMode?: PluginRegistrationMode; }, ): OpenClawPluginApi => { + const registrationMode = params.registrationMode ?? "full"; return { id: record.id, name: record.name, @@ -680,31 +717,50 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { pluginConfig: params.pluginConfig, runtime: registryParams.runtime, logger: normalizeLogger(registryParams.logger), - registerTool: (tool, opts) => registerTool(record, tool, opts), - registerHook: (events, handler, opts) => - registerHook(record, events, handler, opts, params.config), - registerHttpRoute: (params) => registerHttpRoute(record, params), - registerChannel: (registration) => registerChannel(record, registration), - registerProvider: (provider) => registerProvider(record, provider), - registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler), - registerCli: (registrar, opts) => registerCli(record, registrar, opts), - registerService: (service) => registerService(record, service), - registerInteractiveHandler: (registration) => { - const result = registerPluginInteractiveHandler(record.id, registration, { - pluginName: record.name, - pluginRoot: record.rootDir, - }); - if (!result.ok) { - pushDiagnostic({ - level: "warn", - pluginId: record.id, - source: record.source, - message: result.error ?? "interactive handler registration failed", - }); - } - }, - registerCommand: (command) => registerCommand(record, command), + registerTool: + registrationMode === "full" ? (tool, opts) => registerTool(record, tool, opts) : () => {}, + registerHook: + registrationMode === "full" + ? (events, handler, opts) => registerHook(record, events, handler, opts, params.config) + : () => {}, + registerHttpRoute: + registrationMode === "full" ? (params) => registerHttpRoute(record, params) : () => {}, + registerChannel: (registration) => registerChannel(record, registration, registrationMode), + registerProvider: + registrationMode === "full" ? (provider) => registerProvider(record, provider) : () => {}, + registerGatewayMethod: + registrationMode === "full" + ? (method, handler) => registerGatewayMethod(record, method, handler) + : () => {}, + registerCli: + registrationMode === "full" + ? (registrar, opts) => registerCli(record, registrar, opts) + : () => {}, + registerService: + registrationMode === "full" ? (service) => registerService(record, service) : () => {}, + registerInteractiveHandler: + registrationMode === "full" + ? (registration) => { + const result = registerPluginInteractiveHandler(record.id, registration, { + pluginName: record.name, + pluginRoot: record.rootDir, + }); + if (!result.ok) { + pushDiagnostic({ + level: "warn", + pluginId: record.id, + source: record.source, + message: result.error ?? "interactive handler registration failed", + }); + } + } + : () => {}, + registerCommand: + registrationMode === "full" ? (command) => registerCommand(record, command) : () => {}, registerContextEngine: (id, factory) => { + if (registrationMode !== "full") { + return; + } if (id === defaultSlotIdForKey("contextEngine")) { pushDiagnostic({ level: "error", @@ -728,7 +784,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }, resolvePath: (input: string) => resolveUserPath(input), on: (hookName, handler, opts) => - registerTypedHook(record, hookName, handler, opts, params.hookPolicy), + registrationMode === "full" + ? registerTypedHook(record, hookName, handler, opts, params.hookPolicy) + : undefined, }; }; diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 38f850ab2a5..ebec4f2c747 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -18,6 +18,12 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl hooks: [], typedHooks: [], channels: channels as unknown as PluginRegistry["channels"], + channelSetups: channels.map((entry) => ({ + pluginId: entry.pluginId, + plugin: entry.plugin as PluginRegistry["channelSetups"][number]["plugin"], + source: entry.source, + enabled: true, + })), providers: [], gatewayHandlers: {}, httpRoutes: [], From a4047bf148ea1855cff6995e63e64b3a06f525f9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 16:22:12 -0700 Subject: [PATCH 041/943] refactor: move telegram onboarding to setup wizard --- extensions/telegram/src/channel.ts | 82 +----- extensions/telegram/src/onboarding.ts | 262 +------------------ extensions/telegram/src/setup-surface.ts | 312 +++++++++++++++++++++++ src/channels/plugins/setup-wizard.ts | 281 ++++++++++++++++++++ src/channels/plugins/types.plugin.ts | 2 + src/commands/onboarding/registry.ts | 15 ++ 6 files changed, 619 insertions(+), 335 deletions(-) create mode 100644 extensions/telegram/src/setup-surface.ts create mode 100644 src/channels/plugins/setup-wizard.ts diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index a8745591db3..51dc7811764 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -7,7 +7,6 @@ import { formatAllowFromLowercase, } from "openclaw/plugin-sdk/compat"; import { - applyAccountNameToChannelSection, buildChannelConfigSchema, buildTokenChannelStatusSummary, clearAccountEntryFields, @@ -19,7 +18,6 @@ import { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig, looksLikeTelegramTargetId, - migrateBaseNameToDefaultAccount, normalizeAccountId, normalizeTelegramMessagingTarget, PAIRING_APPROVED_MESSAGE, @@ -32,7 +30,6 @@ import { resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, sendTelegramPayloadMessages, - telegramOnboardingAdapter, TelegramConfigSchema, type ChannelMessageActionAdapter, type ChannelPlugin, @@ -45,6 +42,7 @@ import { resolveOutboundSendDep, } from "../../../src/infra/outbound/send-deps.js"; import { getTelegramRuntime } from "./runtime.js"; +import { telegramSetupAdapter, telegramSetupWizard } from "./setup-surface.js"; type TelegramSendFn = ReturnType< typeof getTelegramRuntime @@ -186,7 +184,7 @@ export const telegramPlugin: ChannelPlugin entry.replace(/^(telegram|tg):/i, ""), @@ -297,81 +295,7 @@ export const telegramPlugin: ChannelPlugin listTelegramDirectoryGroupsFromConfig(params), }, actions: telegramMessageActions, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: "telegram", - 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: "telegram", - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "telegram", - }) - : 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 } - : {}), - }, - }, - }, - }, - }; - }, - }, + setup: telegramSetupAdapter, outbound: { deliveryMode: "direct", chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit), diff --git a/extensions/telegram/src/onboarding.ts b/extensions/telegram/src/onboarding.ts index f5911e304ed..340319a864a 100644 --- a/extensions/telegram/src/onboarding.ts +++ b/extensions/telegram/src/onboarding.ts @@ -1,256 +1,6 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../../../src/channels/plugins/onboarding-types.js"; -import { - applySingleTokenPromptResult, - patchChannelConfigForAccount, - promptResolvedAllowFrom, - promptSingleChannelSecretInput, - resolveAccountIdForConfigure, - resolveOnboardingAccountId, - setChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, - splitOnboardingEntries, -} from "../../../src/channels/plugins/onboarding/helpers.js"; -import { formatCliCommand } from "../../../src/cli/command-format.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; -import { inspectTelegramAccount } from "./account-inspect.js"; -import { - listTelegramAccountIds, - resolveDefaultTelegramAccountId, - resolveTelegramAccount, -} from "./accounts.js"; -import { fetchTelegramChatId } from "./api-fetch.js"; - -const channel = "telegram" as const; - -async function noteTelegramTokenHelp(prompter: WizardPrompter): Promise { - await prompter.note( - [ - "1) Open Telegram and chat with @BotFather", - "2) Run /newbot (or /mybots)", - "3) Copy the token (looks like 123456:ABC...)", - "Tip: you can also set TELEGRAM_BOT_TOKEN in your env.", - `Docs: ${formatDocsLink("/telegram")}`, - "Website: https://openclaw.ai", - ].join("\n"), - "Telegram bot token", - ); -} - -async function noteTelegramUserIdHelp(prompter: WizardPrompter): Promise { - await prompter.note( - [ - `1) DM your bot, then read from.id in \`${formatCliCommand("openclaw logs --follow")}\` (safest)`, - "2) Or call https://api.telegram.org/bot/getUpdates and read message.from.id", - "3) Third-party: DM @userinfobot or @getidsbot", - `Docs: ${formatDocsLink("/telegram")}`, - "Website: https://openclaw.ai", - ].join("\n"), - "Telegram user id", - ); -} - -export function normalizeTelegramAllowFromInput(raw: string): string { - return raw - .trim() - .replace(/^(telegram|tg):/i, "") - .trim(); -} - -export function parseTelegramAllowFromId(raw: string): string | null { - const stripped = normalizeTelegramAllowFromInput(raw); - return /^\d+$/.test(stripped) ? stripped : null; -} - -async function promptTelegramAllowFrom(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId: string; - tokenOverride?: string; -}): Promise { - const { cfg, prompter, accountId } = params; - const resolved = resolveTelegramAccount({ cfg, accountId }); - const existingAllowFrom = resolved.config.allowFrom ?? []; - await noteTelegramUserIdHelp(prompter); - - const token = params.tokenOverride?.trim() || resolved.token; - if (!token) { - await prompter.note("Telegram token missing; username lookup is unavailable.", "Telegram"); - } - const unique = await promptResolvedAllowFrom({ - prompter, - existing: existingAllowFrom, - token, - message: "Telegram allowFrom (numeric sender id; @username resolves to id)", - placeholder: "@username", - label: "Telegram allowlist", - parseInputs: splitOnboardingEntries, - parseId: parseTelegramAllowFromId, - invalidWithoutTokenNote: - "Telegram token missing; use numeric sender ids (usernames require a bot token).", - resolveEntries: async ({ token: tokenValue, entries }) => { - const results = await Promise.all( - entries.map(async (entry) => { - const numericId = parseTelegramAllowFromId(entry); - if (numericId) { - return { input: entry, resolved: true, id: numericId }; - } - const stripped = normalizeTelegramAllowFromInput(entry); - if (!stripped) { - return { input: entry, resolved: false, id: null }; - } - const username = stripped.startsWith("@") ? stripped : `@${stripped}`; - const id = await fetchTelegramChatId({ token: tokenValue, chatId: username }); - return { input: entry, resolved: Boolean(id), id }; - }), - ); - return results; - }, - }); - - return patchChannelConfigForAccount({ - cfg, - channel: "telegram", - accountId, - patch: { dmPolicy: "allowlist", allowFrom: unique }, - }); -} - -async function promptTelegramAllowFromForAccount(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - const accountId = resolveOnboardingAccountId({ - accountId: params.accountId, - defaultAccountId: resolveDefaultTelegramAccountId(params.cfg), - }); - return promptTelegramAllowFrom({ - cfg: params.cfg, - prompter: params.prompter, - accountId, - }); -} - -const dmPolicy: ChannelOnboardingDmPolicy = { - label: "Telegram", - channel, - policyKey: "channels.telegram.dmPolicy", - allowFromKey: "channels.telegram.allowFrom", - getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => - setChannelDmPolicyWithAllowFrom({ - cfg, - channel: "telegram", - dmPolicy: policy, - }), - promptAllowFrom: promptTelegramAllowFromForAccount, -}; - -export const telegramOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const configured = listTelegramAccountIds(cfg).some((accountId) => { - const account = inspectTelegramAccount({ cfg, accountId }); - return account.configured; - }); - return { - channel, - configured, - statusLines: [`Telegram: ${configured ? "configured" : "needs token"}`], - selectionHint: configured ? "recommended · configured" : "recommended · newcomer-friendly", - quickstartScore: configured ? 1 : 10, - }; - }, - configure: async ({ - cfg, - prompter, - options, - accountOverrides, - shouldPromptAccountIds, - forceAllowFrom, - }) => { - const defaultTelegramAccountId = resolveDefaultTelegramAccountId(cfg); - const telegramAccountId = await resolveAccountIdForConfigure({ - cfg, - prompter, - label: "Telegram", - accountOverride: accountOverrides.telegram, - shouldPromptAccountIds, - listAccountIds: listTelegramAccountIds, - defaultAccountId: defaultTelegramAccountId, - }); - - let next = cfg; - const resolvedAccount = resolveTelegramAccount({ - cfg: next, - accountId: telegramAccountId, - }); - const hasConfiguredBotToken = hasConfiguredSecretInput(resolvedAccount.config.botToken); - const hasConfigToken = - hasConfiguredBotToken || Boolean(resolvedAccount.config.tokenFile?.trim()); - const accountConfigured = Boolean(resolvedAccount.token) || hasConfigToken; - const allowEnv = telegramAccountId === DEFAULT_ACCOUNT_ID; - const canUseEnv = - allowEnv && !hasConfigToken && Boolean(process.env.TELEGRAM_BOT_TOKEN?.trim()); - - if (!accountConfigured) { - await noteTelegramTokenHelp(prompter); - } - - const tokenResult = await promptSingleChannelSecretInput({ - cfg: next, - prompter, - providerHint: "telegram", - credentialLabel: "Telegram bot token", - secretInputMode: options?.secretInputMode, - accountConfigured, - canUseEnv, - hasConfigToken, - envPrompt: "TELEGRAM_BOT_TOKEN detected. Use env var?", - keepPrompt: "Telegram token already configured. Keep it?", - inputPrompt: "Enter Telegram bot token", - preferredEnvVar: allowEnv ? "TELEGRAM_BOT_TOKEN" : undefined, - }); - - let resolvedTokenForAllowFrom: string | undefined; - if (tokenResult.action === "use-env") { - next = applySingleTokenPromptResult({ - cfg: next, - channel: "telegram", - accountId: telegramAccountId, - tokenPatchKey: "botToken", - tokenResult: { useEnv: true, token: null }, - }); - resolvedTokenForAllowFrom = process.env.TELEGRAM_BOT_TOKEN?.trim() || undefined; - } else if (tokenResult.action === "set") { - next = applySingleTokenPromptResult({ - cfg: next, - channel: "telegram", - accountId: telegramAccountId, - tokenPatchKey: "botToken", - tokenResult: { useEnv: false, token: tokenResult.value }, - }); - resolvedTokenForAllowFrom = tokenResult.resolvedValue; - } - - if (forceAllowFrom) { - next = await promptTelegramAllowFrom({ - cfg: next, - prompter, - accountId: telegramAccountId, - tokenOverride: resolvedTokenForAllowFrom, - }); - } - - return { cfg: next, accountId: telegramAccountId }; - }, - dmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), -}; +export { + normalizeTelegramAllowFromInput, + parseTelegramAllowFromId, + telegramOnboardingAdapter, + telegramSetupWizard, +} from "./setup-surface.js"; diff --git a/extensions/telegram/src/setup-surface.ts b/extensions/telegram/src/setup-surface.ts new file mode 100644 index 00000000000..f2708999fee --- /dev/null +++ b/extensions/telegram/src/setup-surface.ts @@ -0,0 +1,312 @@ +import { + type ChannelOnboardingAdapter, + type ChannelOnboardingDmPolicy, +} from "../../../src/channels/plugins/onboarding-types.js"; +import { + patchChannelConfigForAccount, + promptResolvedAllowFrom, + resolveOnboardingAccountId, + setChannelDmPolicyWithAllowFrom, + setOnboardingChannelEnabled, + splitOnboardingEntries, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import { + buildChannelOnboardingAdapterFromSetupWizard, + type ChannelSetupWizard, +} from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { getChatChannelMeta } from "../../../src/channels/registry.js"; +import { formatCliCommand } from "../../../src/cli/command-format.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { inspectTelegramAccount } from "./account-inspect.js"; +import { + listTelegramAccountIds, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, +} from "./accounts.js"; +import { fetchTelegramChatId } from "./api-fetch.js"; + +const channel = "telegram" as const; + +const TELEGRAM_TOKEN_HELP_LINES = [ + "1) Open Telegram and chat with @BotFather", + "2) Run /newbot (or /mybots)", + "3) Copy the token (looks like 123456:ABC...)", + "Tip: you can also set TELEGRAM_BOT_TOKEN in your env.", + `Docs: ${formatDocsLink("/telegram")}`, + "Website: https://openclaw.ai", +]; + +const TELEGRAM_USER_ID_HELP_LINES = [ + `1) DM your bot, then read from.id in \`${formatCliCommand("openclaw logs --follow")}\` (safest)`, + "2) Or call https://api.telegram.org/bot/getUpdates and read message.from.id", + "3) Third-party: DM @userinfobot or @getidsbot", + `Docs: ${formatDocsLink("/telegram")}`, + "Website: https://openclaw.ai", +]; + +export function normalizeTelegramAllowFromInput(raw: string): string { + return raw + .trim() + .replace(/^(telegram|tg):/i, "") + .trim(); +} + +export function parseTelegramAllowFromId(raw: string): string | null { + const stripped = normalizeTelegramAllowFromInput(raw); + return /^\d+$/.test(stripped) ? stripped : null; +} + +async function resolveTelegramAllowFromEntries(params: { + entries: string[]; + credentialValue?: string; +}) { + return await Promise.all( + params.entries.map(async (entry) => { + const numericId = parseTelegramAllowFromId(entry); + if (numericId) { + return { input: entry, resolved: true, id: numericId }; + } + const stripped = normalizeTelegramAllowFromInput(entry); + if (!stripped || !params.credentialValue?.trim()) { + return { input: entry, resolved: false, id: null }; + } + const username = stripped.startsWith("@") ? stripped : `@${stripped}`; + const id = await fetchTelegramChatId({ + token: params.credentialValue, + chatId: username, + }); + return { input: entry, resolved: Boolean(id), id }; + }), + ); +} + +async function promptTelegramAllowFromForAccount(params: { + cfg: OpenClawConfig; + prompter: Parameters>[0]["prompter"]; + accountId?: string; +}): Promise { + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultTelegramAccountId(params.cfg), + }); + const resolved = resolveTelegramAccount({ cfg: params.cfg, accountId }); + await params.prompter.note(TELEGRAM_USER_ID_HELP_LINES.join("\n"), "Telegram user id"); + if (!resolved.token?.trim()) { + await params.prompter.note( + "Telegram token missing; username lookup is unavailable.", + "Telegram", + ); + } + const unique = await promptResolvedAllowFrom({ + prompter: params.prompter, + existing: resolved.config.allowFrom ?? [], + token: resolved.token, + message: "Telegram allowFrom (numeric sender id; @username resolves to id)", + placeholder: "@username", + label: "Telegram allowlist", + parseInputs: splitOnboardingEntries, + parseId: parseTelegramAllowFromId, + invalidWithoutTokenNote: + "Telegram token missing; use numeric sender ids (usernames require a bot token).", + resolveEntries: async ({ entries, token }) => + resolveTelegramAllowFromEntries({ + credentialValue: token, + entries, + }), + }); + return patchChannelConfigForAccount({ + cfg: params.cfg, + channel, + accountId, + patch: { dmPolicy: "allowlist", allowFrom: unique }, + }); +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "Telegram", + channel, + policyKey: "channels.telegram.dmPolicy", + allowFromKey: "channels.telegram.allowFrom", + getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => + setChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), + promptAllowFrom: promptTelegramAllowFromForAccount, +}; + +export const telegramSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "TELEGRAM_BOT_TOKEN can only be used for the default account."; + } + if (!input.useEnv && !input.token && !input.tokenFile) { + return "Telegram requires token or --token-file (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + telegram: { + ...next.channels?.telegram, + enabled: true, + ...(input.useEnv + ? {} + : input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + telegram: { + ...next.channels?.telegram, + enabled: true, + accounts: { + ...next.channels?.telegram?.accounts, + [accountId]: { + ...next.channels?.telegram?.accounts?.[accountId], + enabled: true, + ...(input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}), + }, + }, + }, + }, + }; + }, +}; + +export const telegramSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs token", + configuredHint: "recommended · configured", + unconfiguredHint: "recommended · newcomer-friendly", + configuredScore: 1, + unconfiguredScore: 10, + resolveConfigured: ({ cfg }) => + listTelegramAccountIds(cfg).some((accountId) => { + const account = inspectTelegramAccount({ cfg, accountId }); + return account.configured; + }), + }, + credential: { + inputKey: "token", + providerHint: channel, + credentialLabel: "Telegram bot token", + preferredEnvVar: "TELEGRAM_BOT_TOKEN", + helpTitle: "Telegram bot token", + helpLines: TELEGRAM_TOKEN_HELP_LINES, + envPrompt: "TELEGRAM_BOT_TOKEN detected. Use env var?", + keepPrompt: "Telegram token already configured. Keep it?", + inputPrompt: "Enter Telegram bot token", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const resolved = resolveTelegramAccount({ cfg, accountId }); + const hasConfiguredBotToken = hasConfiguredSecretInput(resolved.config.botToken); + const hasConfiguredValue = + hasConfiguredBotToken || Boolean(resolved.config.tokenFile?.trim()); + return { + accountConfigured: Boolean(resolved.token) || hasConfiguredValue, + hasConfiguredValue, + resolvedValue: resolved.token?.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.TELEGRAM_BOT_TOKEN?.trim() || undefined + : undefined, + }; + }, + }, + allowFrom: { + helpTitle: "Telegram user id", + helpLines: TELEGRAM_USER_ID_HELP_LINES, + message: "Telegram allowFrom (numeric sender id; @username resolves to id)", + placeholder: "@username", + invalidWithoutCredentialNote: + "Telegram token missing; use numeric sender ids (usernames require a bot token).", + parseInputs: splitOnboardingEntries, + parseId: parseTelegramAllowFromId, + resolveEntries: async ({ credentialValue, entries }) => + resolveTelegramAllowFromEntries({ + credentialValue, + entries, + }), + apply: async ({ cfg, accountId, allowFrom }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { dmPolicy: "allowlist", allowFrom }, + }), + }, + dmPolicy, + disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), +}; + +const telegramSetupPlugin = { + id: channel, + meta: { + ...getChatChannelMeta(channel), + quickstartAllowFrom: true, + }, + config: { + listAccountIds: listTelegramAccountIds, + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => + resolveTelegramAccount({ cfg, accountId }), + resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => + resolveTelegramAccount({ cfg, accountId }).config.allowFrom, + }, + setup: telegramSetupAdapter, +} as const; + +export const telegramOnboardingAdapter: ChannelOnboardingAdapter = + buildChannelOnboardingAdapterFromSetupWizard({ + plugin: telegramSetupPlugin, + wizard: telegramSetupWizard, + }); diff --git a/src/channels/plugins/setup-wizard.ts b/src/channels/plugins/setup-wizard.ts new file mode 100644 index 00000000000..6653c21ee73 --- /dev/null +++ b/src/channels/plugins/setup-wizard.ts @@ -0,0 +1,281 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { resolveChannelDefaultAccountId } from "./helpers.js"; +import type { + ChannelOnboardingAdapter, + ChannelOnboardingDmPolicy, + ChannelOnboardingStatus, + ChannelOnboardingStatusContext, +} from "./onboarding-types.js"; +import { + promptResolvedAllowFrom, + resolveAccountIdForConfigure, + runSingleChannelSecretStep, + splitOnboardingEntries, +} from "./onboarding/helpers.js"; +import type { ChannelSetupInput } from "./types.core.js"; +import type { ChannelPlugin } from "./types.js"; + +export type ChannelSetupWizardStatus = { + configuredLabel: string; + unconfiguredLabel: string; + configuredHint?: string; + unconfiguredHint?: string; + configuredScore?: number; + unconfiguredScore?: number; + resolveConfigured: (params: { cfg: OpenClawConfig }) => boolean | Promise; +}; + +export type ChannelSetupWizardCredentialState = { + accountConfigured: boolean; + hasConfiguredValue: boolean; + resolvedValue?: string; + envValue?: string; +}; + +export type ChannelSetupWizardCredential = { + inputKey: keyof ChannelSetupInput; + providerHint: string; + credentialLabel: string; + preferredEnvVar?: string; + helpTitle?: string; + helpLines?: string[]; + envPrompt: string; + keepPrompt: string; + inputPrompt: string; + allowEnv?: (params: { cfg: OpenClawConfig; accountId: string }) => boolean; + inspect: (params: { + cfg: OpenClawConfig; + accountId: string; + }) => ChannelSetupWizardCredentialState; +}; + +export type ChannelSetupWizardAllowFromEntry = { + input: string; + resolved: boolean; + id: string | null; +}; + +export type ChannelSetupWizardAllowFrom = { + helpTitle?: string; + helpLines?: string[]; + message: string; + placeholder?: string; + invalidWithoutCredentialNote?: string; + parseInputs?: (raw: string) => string[]; + parseId: (raw: string) => string | null; + resolveEntries?: (params: { + cfg: OpenClawConfig; + accountId: string; + credentialValue?: string; + entries: string[]; + }) => Promise; + apply: (params: { + cfg: OpenClawConfig; + accountId: string; + allowFrom: string[]; + }) => OpenClawConfig | Promise; +}; + +export type ChannelSetupWizard = { + channel: string; + status: ChannelSetupWizardStatus; + credential: ChannelSetupWizardCredential; + dmPolicy?: ChannelOnboardingDmPolicy; + allowFrom?: ChannelSetupWizardAllowFrom; + disable?: (cfg: OpenClawConfig) => OpenClawConfig; + onAccountRecorded?: ChannelOnboardingAdapter["onAccountRecorded"]; +}; + +type ChannelSetupWizardPlugin = Pick; + +async function buildStatus( + plugin: ChannelSetupWizardPlugin, + wizard: ChannelSetupWizard, + ctx: ChannelOnboardingStatusContext, +): Promise { + const configured = await wizard.status.resolveConfigured({ cfg: ctx.cfg }); + return { + channel: plugin.id, + configured, + statusLines: [ + `${plugin.meta.label}: ${configured ? wizard.status.configuredLabel : wizard.status.unconfiguredLabel}`, + ], + selectionHint: configured ? wizard.status.configuredHint : wizard.status.unconfiguredHint, + quickstartScore: configured ? wizard.status.configuredScore : wizard.status.unconfiguredScore, + }; +} + +function applySetupInput(params: { + plugin: ChannelSetupWizardPlugin; + cfg: OpenClawConfig; + accountId: string; + input: ChannelSetupInput; +}) { + const setup = params.plugin.setup; + if (!setup?.applyAccountConfig) { + throw new Error(`${params.plugin.id} does not support setup`); + } + const resolvedAccountId = + setup.resolveAccountId?.({ + cfg: params.cfg, + accountId: params.accountId, + input: params.input, + }) ?? params.accountId; + const validationError = setup.validateInput?.({ + cfg: params.cfg, + accountId: resolvedAccountId, + input: params.input, + }); + if (validationError) { + throw new Error(validationError); + } + let next = setup.applyAccountConfig({ + cfg: params.cfg, + accountId: resolvedAccountId, + input: params.input, + }); + if (params.input.name?.trim() && setup.applyAccountName) { + next = setup.applyAccountName({ + cfg: next, + accountId: resolvedAccountId, + name: params.input.name, + }); + } + return { + cfg: next, + accountId: resolvedAccountId, + }; +} + +export function buildChannelOnboardingAdapterFromSetupWizard(params: { + plugin: ChannelSetupWizardPlugin; + wizard: ChannelSetupWizard; +}): ChannelOnboardingAdapter { + const { plugin, wizard } = params; + return { + channel: plugin.id, + getStatus: async (ctx) => buildStatus(plugin, wizard, ctx), + configure: async ({ + cfg, + prompter, + options, + accountOverrides, + shouldPromptAccountIds, + forceAllowFrom, + }) => { + const defaultAccountId = resolveChannelDefaultAccountId({ plugin, cfg }); + const accountId = await resolveAccountIdForConfigure({ + cfg, + prompter, + label: plugin.meta.label, + accountOverride: accountOverrides[plugin.id], + shouldPromptAccountIds, + listAccountIds: plugin.config.listAccountIds, + defaultAccountId, + }); + + let next = cfg; + let credentialState = wizard.credential.inspect({ cfg: next, accountId }); + let resolvedCredentialValue = credentialState.resolvedValue?.trim() || undefined; + const allowEnv = wizard.credential.allowEnv?.({ cfg: next, accountId }) ?? false; + + const credentialResult = await runSingleChannelSecretStep({ + cfg: next, + prompter, + providerHint: wizard.credential.providerHint, + credentialLabel: wizard.credential.credentialLabel, + secretInputMode: options?.secretInputMode, + accountConfigured: credentialState.accountConfigured, + hasConfigToken: credentialState.hasConfiguredValue, + allowEnv, + envValue: credentialState.envValue, + envPrompt: wizard.credential.envPrompt, + keepPrompt: wizard.credential.keepPrompt, + inputPrompt: wizard.credential.inputPrompt, + preferredEnvVar: wizard.credential.preferredEnvVar, + onMissingConfigured: + wizard.credential.helpLines && wizard.credential.helpLines.length > 0 + ? async () => { + await prompter.note( + wizard.credential.helpLines!.join("\n"), + wizard.credential.helpTitle ?? wizard.credential.credentialLabel, + ); + } + : undefined, + applyUseEnv: async (currentCfg) => + applySetupInput({ + plugin, + cfg: currentCfg, + accountId, + input: { + [wizard.credential.inputKey]: undefined, + useEnv: true, + }, + }).cfg, + applySet: async (currentCfg, value, resolvedValue) => { + resolvedCredentialValue = resolvedValue; + return applySetupInput({ + plugin, + cfg: currentCfg, + accountId, + input: { + [wizard.credential.inputKey]: value, + useEnv: false, + }, + }).cfg; + }, + }); + + next = credentialResult.cfg; + credentialState = wizard.credential.inspect({ cfg: next, accountId }); + resolvedCredentialValue = + credentialResult.resolvedValue?.trim() || + credentialState.resolvedValue?.trim() || + undefined; + + if (forceAllowFrom && wizard.allowFrom) { + if (wizard.allowFrom.helpLines && wizard.allowFrom.helpLines.length > 0) { + await prompter.note( + wizard.allowFrom.helpLines.join("\n"), + wizard.allowFrom.helpTitle ?? `${plugin.meta.label} allowlist`, + ); + } + const existingAllowFrom = + plugin.config.resolveAllowFrom?.({ + cfg: next, + accountId, + }) ?? []; + const unique = await promptResolvedAllowFrom({ + prompter, + existing: existingAllowFrom, + token: resolvedCredentialValue, + message: wizard.allowFrom.message, + placeholder: wizard.allowFrom.placeholder, + label: wizard.allowFrom.helpTitle ?? `${plugin.meta.label} allowlist`, + parseInputs: wizard.allowFrom.parseInputs ?? splitOnboardingEntries, + parseId: wizard.allowFrom.parseId, + invalidWithoutTokenNote: wizard.allowFrom.invalidWithoutCredentialNote, + resolveEntries: wizard.allowFrom.resolveEntries + ? async ({ entries }) => + wizard.allowFrom!.resolveEntries!({ + cfg: next, + accountId, + credentialValue: resolvedCredentialValue, + entries, + }) + : undefined, + }); + next = await wizard.allowFrom.apply({ + cfg: next, + accountId, + allowFrom: unique, + }); + } + + return { cfg: next, accountId }; + }, + dmPolicy: wizard.dmPolicy, + disable: wizard.disable, + onAccountRecorded: wizard.onAccountRecorded, + }; +} diff --git a/src/channels/plugins/types.plugin.ts b/src/channels/plugins/types.plugin.ts index a0d5aabadc7..3c821ab601b 100644 --- a/src/channels/plugins/types.plugin.ts +++ b/src/channels/plugins/types.plugin.ts @@ -1,4 +1,5 @@ import type { ChannelOnboardingAdapter } from "./onboarding-types.js"; +import type { ChannelSetupWizard } from "./setup-wizard.js"; import type { ChannelAuthAdapter, ChannelCommandAdapter, @@ -58,6 +59,7 @@ export type ChannelPlugin; configSchema?: ChannelConfigSchema; setup?: ChannelSetupAdapter; diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts index 3f7bea2da19..536d745a446 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/onboarding/registry.ts @@ -1,10 +1,25 @@ import { listChannelSetupPlugins } from "../../channels/plugins/setup-registry.js"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../channels/plugins/setup-wizard.js"; import type { ChannelChoice } from "../onboard-types.js"; import type { ChannelOnboardingAdapter } from "./types.js"; +const setupWizardAdapters = new WeakMap(); + function resolveChannelOnboardingAdapter( plugin: (typeof listChannelSetupPlugins)[number], ): ChannelOnboardingAdapter | undefined { + if (plugin.setupWizard) { + const cached = setupWizardAdapters.get(plugin); + if (cached) { + return cached; + } + const adapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin, + wizard: plugin.setupWizard, + }); + setupWizardAdapters.set(plugin, adapter); + return adapter; + } if (plugin.onboarding) { return plugin.onboarding; } From d040d48af4c54bc855afe88dd0f97ced9670aab2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 16:22:47 -0700 Subject: [PATCH 042/943] docs: describe channel setup wizard surface --- docs/tools/plugin.md | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 23eb378193e..dd70badb37a 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1373,28 +1373,33 @@ Notes: - `meta.preferOver` lists channel ids to skip auto-enable when both are configured. - `meta.detailLabel` and `meta.systemImage` let UIs show richer channel labels/icons. -### Channel onboarding hooks +### Channel setup hooks -Channel plugins can define optional onboarding hooks on `plugin.onboarding`: +Preferred setup split: -- `configure(ctx)` is the baseline setup flow. -- `configureInteractive(ctx)` can fully own interactive setup for both configured and unconfigured states. -- `configureWhenConfigured(ctx)` can override behavior only for already configured channels. +- `plugin.setup` owns account-id normalization, validation, and config writes. +- `plugin.setupWizard` lets the host run the common wizard flow while the channel only supplies status/credential/allowlist descriptors. -Hook precedence in the wizard: +Use `plugin.onboarding` only when the host-owned setup wizard cannot express the flow and the +channel needs to fully own prompting. -1. `configureInteractive` (if present) -2. `configureWhenConfigured` (only when channel status is already configured) -3. fallback to `configure` +Wizard precedence: -Context details: +1. `plugin.setupWizard` (preferred, host-owned prompts) +2. `plugin.onboarding.configureInteractive` +3. `plugin.onboarding.configureWhenConfigured` (already-configured channel only) +4. `plugin.onboarding.configure` -- `configureInteractive` and `configureWhenConfigured` receive: - - `configured` (`true` or `false`) - - `label` (user-facing channel name used by prompts) - - plus the shared config/runtime/prompter/options fields -- Returning `"skip"` leaves selection and account tracking unchanged. -- Returning `{ cfg, accountId? }` applies config updates and records account selection. +`plugin.setupWizard` is best for channels that fit the shared pattern: + +- one account picker driven by `plugin.config.listAccountIds` +- one primary credential prompt written via `plugin.setup.applyAccountConfig` +- optional DM allowlist resolution (for example `@username` -> numeric id) + +`plugin.onboarding` hooks still return the same values as before: + +- `"skip"` leaves selection and account tracking unchanged. +- `{ cfg, accountId? }` applies config updates and records account selection. ### Write a new messaging channel (step‑by‑step) @@ -1421,7 +1426,7 @@ Model provider docs live under `/providers/*`. 4. Add optional adapters as needed -- `setup` (wizard), `security` (DM policy), `status` (health/diagnostics) +- `setup` (validation + config writes), `setupWizard` (host-owned wizard), `security` (DM policy), `status` (health/diagnostics) - `gateway` (start/stop/login), `mentions`, `threading`, `streaming` - `actions` (message actions), `commands` (native command behavior) From fd7e283ac5b7095170958155019a3249010f7b01 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 16:24:50 -0700 Subject: [PATCH 043/943] fix: tighten setup wizard typing --- src/channels/plugins/setup-wizard.ts | 50 +++++++++++++++------------- src/commands/onboarding/registry.ts | 2 +- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/channels/plugins/setup-wizard.ts b/src/channels/plugins/setup-wizard.ts index 6653c21ee73..6dc464dc6af 100644 --- a/src/channels/plugins/setup-wizard.ts +++ b/src/channels/plugins/setup-wizard.ts @@ -1,5 +1,5 @@ import type { OpenClawConfig } from "../../config/config.js"; -import { resolveChannelDefaultAccountId } from "./helpers.js"; +import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy, @@ -59,11 +59,11 @@ export type ChannelSetupWizardAllowFrom = { helpTitle?: string; helpLines?: string[]; message: string; - placeholder?: string; - invalidWithoutCredentialNote?: string; + placeholder: string; + invalidWithoutCredentialNote: string; parseInputs?: (raw: string) => string[]; parseId: (raw: string) => string | null; - resolveEntries?: (params: { + resolveEntries: (params: { cfg: OpenClawConfig; accountId: string; credentialValue?: string; @@ -163,7 +163,10 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { shouldPromptAccountIds, forceAllowFrom, }) => { - const defaultAccountId = resolveChannelDefaultAccountId({ plugin, cfg }); + const defaultAccountId = + plugin.config.defaultAccountId?.(cfg) ?? + plugin.config.listAccountIds(cfg)[0] ?? + DEFAULT_ACCOUNT_ID; const accountId = await resolveAccountIdForConfigure({ cfg, prompter, @@ -234,10 +237,11 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { undefined; if (forceAllowFrom && wizard.allowFrom) { - if (wizard.allowFrom.helpLines && wizard.allowFrom.helpLines.length > 0) { + const allowFrom = wizard.allowFrom; + if (allowFrom.helpLines && allowFrom.helpLines.length > 0) { await prompter.note( - wizard.allowFrom.helpLines.join("\n"), - wizard.allowFrom.helpTitle ?? `${plugin.meta.label} allowlist`, + allowFrom.helpLines.join("\n"), + allowFrom.helpTitle ?? `${plugin.meta.label} allowlist`, ); } const existingAllowFrom = @@ -249,23 +253,21 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { prompter, existing: existingAllowFrom, token: resolvedCredentialValue, - message: wizard.allowFrom.message, - placeholder: wizard.allowFrom.placeholder, - label: wizard.allowFrom.helpTitle ?? `${plugin.meta.label} allowlist`, - parseInputs: wizard.allowFrom.parseInputs ?? splitOnboardingEntries, - parseId: wizard.allowFrom.parseId, - invalidWithoutTokenNote: wizard.allowFrom.invalidWithoutCredentialNote, - resolveEntries: wizard.allowFrom.resolveEntries - ? async ({ entries }) => - wizard.allowFrom!.resolveEntries!({ - cfg: next, - accountId, - credentialValue: resolvedCredentialValue, - entries, - }) - : undefined, + message: allowFrom.message, + placeholder: allowFrom.placeholder, + label: allowFrom.helpTitle ?? `${plugin.meta.label} allowlist`, + parseInputs: allowFrom.parseInputs ?? splitOnboardingEntries, + parseId: allowFrom.parseId, + invalidWithoutTokenNote: allowFrom.invalidWithoutCredentialNote, + resolveEntries: async ({ entries }) => + allowFrom.resolveEntries({ + cfg: next, + accountId, + credentialValue: resolvedCredentialValue, + entries, + }), }); - next = await wizard.allowFrom.apply({ + next = await allowFrom.apply({ cfg: next, accountId, allowFrom: unique, diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts index 536d745a446..d8825abc853 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/onboarding/registry.ts @@ -6,7 +6,7 @@ import type { ChannelOnboardingAdapter } from "./types.js"; const setupWizardAdapters = new WeakMap(); function resolveChannelOnboardingAdapter( - plugin: (typeof listChannelSetupPlugins)[number], + plugin: ReturnType[number], ): ChannelOnboardingAdapter | undefined { if (plugin.setupWizard) { const cached = setupWizardAdapters.get(plugin); From c74042ba04515894e584ad8a76c9e7b7b92fec54 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 16:40:51 -0700 Subject: [PATCH 044/943] Commands: lazy-load auth choice plugin provider runtime (#47692) * Commands: lazy-load auth choice plugin provider runtime * Tests: cover auth choice plugin provider runtime --- .../auth-choice.apply.plugin-provider.runtime.ts | 5 +++++ .../auth-choice.apply.plugin-provider.test.ts | 7 ++----- src/commands/auth-choice.apply.plugin-provider.ts | 13 ++++++++----- 3 files changed, 15 insertions(+), 10 deletions(-) create mode 100644 src/commands/auth-choice.apply.plugin-provider.runtime.ts diff --git a/src/commands/auth-choice.apply.plugin-provider.runtime.ts b/src/commands/auth-choice.apply.plugin-provider.runtime.ts new file mode 100644 index 00000000000..9fb990318ad --- /dev/null +++ b/src/commands/auth-choice.apply.plugin-provider.runtime.ts @@ -0,0 +1,5 @@ +export { + resolveProviderPluginChoice, + runProviderModelSelectedHook, +} from "../plugins/provider-wizard.js"; +export { resolvePluginProviders } from "../plugins/providers.js"; diff --git a/src/commands/auth-choice.apply.plugin-provider.test.ts b/src/commands/auth-choice.apply.plugin-provider.test.ts index 2557fcd2f5c..27615989d1d 100644 --- a/src/commands/auth-choice.apply.plugin-provider.test.ts +++ b/src/commands/auth-choice.apply.plugin-provider.test.ts @@ -9,15 +9,12 @@ import { } from "./auth-choice.apply.plugin-provider.js"; const resolvePluginProviders = vi.hoisted(() => vi.fn<() => ProviderPlugin[]>(() => [])); -vi.mock("../plugins/providers.js", () => ({ - resolvePluginProviders, -})); - const resolveProviderPluginChoice = vi.hoisted(() => vi.fn<() => { provider: ProviderPlugin; method: ProviderAuthMethod } | null>(), ); const runProviderModelSelectedHook = vi.hoisted(() => vi.fn(async () => {})); -vi.mock("../plugins/provider-wizard.js", () => ({ +vi.mock("./auth-choice.apply.plugin-provider.runtime.js", () => ({ + resolvePluginProviders, resolveProviderPluginChoice, runProviderModelSelectedHook, })); diff --git a/src/commands/auth-choice.apply.plugin-provider.ts b/src/commands/auth-choice.apply.plugin-provider.ts index bd97928db91..2268a34d3ff 100644 --- a/src/commands/auth-choice.apply.plugin-provider.ts +++ b/src/commands/auth-choice.apply.plugin-provider.ts @@ -7,11 +7,6 @@ import { import { upsertAuthProfile } from "../agents/auth-profiles.js"; import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; import { enablePluginInConfig } from "../plugins/enable.js"; -import { - resolveProviderPluginChoice, - runProviderModelSelectedHook, -} from "../plugins/provider-wizard.js"; -import { resolvePluginProviders } from "../plugins/providers.js"; import type { ProviderAuthMethod } from "../plugins/types.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import { isRemoteEnvironment } from "./oauth-env.js"; @@ -33,6 +28,10 @@ export type PluginProviderAuthChoiceOptions = { label: string; }; +async function loadPluginProviderRuntime() { + return import("./auth-choice.apply.plugin-provider.runtime.js"); +} + export async function runProviderPluginAuthMethod(params: { config: ApplyAuthChoiceParams["config"]; runtime: ApplyAuthChoiceParams["runtime"]; @@ -109,6 +108,8 @@ export async function applyAuthChoiceLoadedPluginProvider( const agentId = params.agentId ?? resolveDefaultAgentId(params.config); const workspaceDir = resolveAgentWorkspaceDir(params.config, agentId) ?? resolveDefaultAgentWorkspaceDir(); + const { resolvePluginProviders, resolveProviderPluginChoice, runProviderModelSelectedHook } = + await loadPluginProviderRuntime(); const providers = resolvePluginProviders({ config: params.config, workspaceDir }); const resolved = resolveProviderPluginChoice({ providers, @@ -177,6 +178,8 @@ export async function applyAuthChoicePluginProvider( const workspaceDir = resolveAgentWorkspaceDir(nextConfig, agentId) ?? resolveDefaultAgentWorkspaceDir(); + const { resolvePluginProviders, runProviderModelSelectedHook } = + await loadPluginProviderRuntime(); const providers = resolvePluginProviders({ config: nextConfig, workspaceDir }); const provider = resolveProviderMatch(providers, options.providerId); if (!provider) { From 6e047eb683c05e157e54e56b1f8534f3fd041a59 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 16:47:39 -0700 Subject: [PATCH 045/943] refactor: expand setup wizard flow --- extensions/telegram/src/setup-surface.ts | 92 +++---- src/channels/plugins/setup-wizard.ts | 302 ++++++++++++++++++----- 2 files changed, 277 insertions(+), 117 deletions(-) diff --git a/extensions/telegram/src/setup-surface.ts b/extensions/telegram/src/setup-surface.ts index f2708999fee..bb46fc963ac 100644 --- a/extensions/telegram/src/setup-surface.ts +++ b/extensions/telegram/src/setup-surface.ts @@ -1,7 +1,4 @@ -import { - type ChannelOnboardingAdapter, - type ChannelOnboardingDmPolicy, -} from "../../../src/channels/plugins/onboarding-types.js"; +import { type ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { patchChannelConfigForAccount, promptResolvedAllowFrom, @@ -14,12 +11,8 @@ import { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, } from "../../../src/channels/plugins/setup-helpers.js"; -import { - buildChannelOnboardingAdapterFromSetupWizard, - type ChannelSetupWizard, -} from "../../../src/channels/plugins/setup-wizard.js"; +import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import { getChatChannelMeta } from "../../../src/channels/registry.js"; import { formatCliCommand } from "../../../src/cli/command-format.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; @@ -236,45 +229,48 @@ export const telegramSetupWizard: ChannelSetupWizard = { return account.configured; }), }, - credential: { - inputKey: "token", - providerHint: channel, - credentialLabel: "Telegram bot token", - preferredEnvVar: "TELEGRAM_BOT_TOKEN", - helpTitle: "Telegram bot token", - helpLines: TELEGRAM_TOKEN_HELP_LINES, - envPrompt: "TELEGRAM_BOT_TOKEN detected. Use env var?", - keepPrompt: "Telegram token already configured. Keep it?", - inputPrompt: "Enter Telegram bot token", - allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, - inspect: ({ cfg, accountId }) => { - const resolved = resolveTelegramAccount({ cfg, accountId }); - const hasConfiguredBotToken = hasConfiguredSecretInput(resolved.config.botToken); - const hasConfiguredValue = - hasConfiguredBotToken || Boolean(resolved.config.tokenFile?.trim()); - return { - accountConfigured: Boolean(resolved.token) || hasConfiguredValue, - hasConfiguredValue, - resolvedValue: resolved.token?.trim() || undefined, - envValue: - accountId === DEFAULT_ACCOUNT_ID - ? process.env.TELEGRAM_BOT_TOKEN?.trim() || undefined - : undefined, - }; + credentials: [ + { + inputKey: "token", + providerHint: channel, + credentialLabel: "Telegram bot token", + preferredEnvVar: "TELEGRAM_BOT_TOKEN", + helpTitle: "Telegram bot token", + helpLines: TELEGRAM_TOKEN_HELP_LINES, + envPrompt: "TELEGRAM_BOT_TOKEN detected. Use env var?", + keepPrompt: "Telegram token already configured. Keep it?", + inputPrompt: "Enter Telegram bot token", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const resolved = resolveTelegramAccount({ cfg, accountId }); + const hasConfiguredBotToken = hasConfiguredSecretInput(resolved.config.botToken); + const hasConfiguredValue = + hasConfiguredBotToken || Boolean(resolved.config.tokenFile?.trim()); + return { + accountConfigured: Boolean(resolved.token) || hasConfiguredValue, + hasConfiguredValue, + resolvedValue: resolved.token?.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.TELEGRAM_BOT_TOKEN?.trim() || undefined + : undefined, + }; + }, }, - }, + ], allowFrom: { helpTitle: "Telegram user id", helpLines: TELEGRAM_USER_ID_HELP_LINES, + credentialInputKey: "token", message: "Telegram allowFrom (numeric sender id; @username resolves to id)", placeholder: "@username", invalidWithoutCredentialNote: "Telegram token missing; use numeric sender ids (usernames require a bot token).", parseInputs: splitOnboardingEntries, parseId: parseTelegramAllowFromId, - resolveEntries: async ({ credentialValue, entries }) => + resolveEntries: async ({ credentialValues, entries }) => resolveTelegramAllowFromEntries({ - credentialValue, + credentialValue: credentialValues.token, entries, }), apply: async ({ cfg, accountId, allowFrom }) => @@ -288,25 +284,3 @@ export const telegramSetupWizard: ChannelSetupWizard = { dmPolicy, disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), }; - -const telegramSetupPlugin = { - id: channel, - meta: { - ...getChatChannelMeta(channel), - quickstartAllowFrom: true, - }, - config: { - listAccountIds: listTelegramAccountIds, - resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => - resolveTelegramAccount({ cfg, accountId }), - resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => - resolveTelegramAccount({ cfg, accountId }).config.allowFrom, - }, - setup: telegramSetupAdapter, -} as const; - -export const telegramOnboardingAdapter: ChannelOnboardingAdapter = - buildChannelOnboardingAdapterFromSetupWizard({ - plugin: telegramSetupPlugin, - wizard: telegramSetupWizard, - }); diff --git a/src/channels/plugins/setup-wizard.ts b/src/channels/plugins/setup-wizard.ts index 6dc464dc6af..e19c2b57ee6 100644 --- a/src/channels/plugins/setup-wizard.ts +++ b/src/channels/plugins/setup-wizard.ts @@ -1,11 +1,14 @@ import type { OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; +import type { WizardPrompter } from "../../wizard/prompts.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy, ChannelOnboardingStatus, ChannelOnboardingStatusContext, } from "./onboarding-types.js"; +import { configureChannelAccessWithAllowlist } from "./onboarding/channel-access-configure.js"; +import type { ChannelAccessPolicy } from "./onboarding/channel-access.js"; import { promptResolvedAllowFrom, resolveAccountIdForConfigure, @@ -32,6 +35,28 @@ export type ChannelSetupWizardCredentialState = { envValue?: string; }; +type ChannelSetupWizardCredentialValues = Partial>; + +export type ChannelSetupWizardNote = { + title: string; + lines: string[]; + shouldShow?: (params: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: ChannelSetupWizardCredentialValues; + }) => boolean | Promise; +}; + +export type ChannelSetupWizardEnvShortcut = { + prompt: string; + preferredEnvVar?: string; + isAvailable: (params: { cfg: OpenClawConfig; accountId: string }) => boolean; + apply: (params: { + cfg: OpenClawConfig; + accountId: string; + }) => OpenClawConfig | Promise; +}; + export type ChannelSetupWizardCredential = { inputKey: keyof ChannelSetupInput; providerHint: string; @@ -47,6 +72,16 @@ export type ChannelSetupWizardCredential = { cfg: OpenClawConfig; accountId: string; }) => ChannelSetupWizardCredentialState; + applyUseEnv?: (params: { + cfg: OpenClawConfig; + accountId: string; + }) => OpenClawConfig | Promise; + applySet?: (params: { + cfg: OpenClawConfig; + accountId: string; + value: unknown; + resolvedValue: string; + }) => OpenClawConfig | Promise; }; export type ChannelSetupWizardAllowFromEntry = { @@ -58,6 +93,7 @@ export type ChannelSetupWizardAllowFromEntry = { export type ChannelSetupWizardAllowFrom = { helpTitle?: string; helpLines?: string[]; + credentialInputKey?: keyof ChannelSetupInput; message: string; placeholder: string; invalidWithoutCredentialNote: string; @@ -66,7 +102,7 @@ export type ChannelSetupWizardAllowFrom = { resolveEntries: (params: { cfg: OpenClawConfig; accountId: string; - credentialValue?: string; + credentialValues: ChannelSetupWizardCredentialValues; entries: string[]; }) => Promise; apply: (params: { @@ -76,12 +112,42 @@ export type ChannelSetupWizardAllowFrom = { }) => OpenClawConfig | Promise; }; +export type ChannelSetupWizardGroupAccess = { + label: string; + placeholder: string; + helpTitle?: string; + helpLines?: string[]; + currentPolicy: (params: { cfg: OpenClawConfig; accountId: string }) => ChannelAccessPolicy; + currentEntries: (params: { cfg: OpenClawConfig; accountId: string }) => string[]; + updatePrompt: (params: { cfg: OpenClawConfig; accountId: string }) => boolean; + setPolicy: (params: { + cfg: OpenClawConfig; + accountId: string; + policy: ChannelAccessPolicy; + }) => OpenClawConfig; + resolveAllowlist: (params: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: ChannelSetupWizardCredentialValues; + entries: string[]; + prompter: Pick; + }) => Promise; + applyAllowlist: (params: { + cfg: OpenClawConfig; + accountId: string; + resolved: unknown; + }) => OpenClawConfig; +}; + export type ChannelSetupWizard = { channel: string; status: ChannelSetupWizardStatus; - credential: ChannelSetupWizardCredential; + introNote?: ChannelSetupWizardNote; + envShortcut?: ChannelSetupWizardEnvShortcut; + credentials: ChannelSetupWizardCredential[]; dmPolicy?: ChannelOnboardingDmPolicy; allowFrom?: ChannelSetupWizardAllowFrom; + groupAccess?: ChannelSetupWizardGroupAccess; disable?: (cfg: OpenClawConfig) => OpenClawConfig; onAccountRecorded?: ChannelOnboardingAdapter["onAccountRecorded"]; }; @@ -147,6 +213,31 @@ function applySetupInput(params: { }; } +function trimResolvedValue(value?: string): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function collectCredentialValues(params: { + wizard: ChannelSetupWizard; + cfg: OpenClawConfig; + accountId: string; +}): ChannelSetupWizardCredentialValues { + const values: ChannelSetupWizardCredentialValues = {}; + for (const credential of params.wizard.credentials) { + const resolvedValue = trimResolvedValue( + credential.inspect({ + cfg: params.cfg, + accountId: params.accountId, + }).resolvedValue, + ); + if (resolvedValue) { + values[credential.inputKey] = resolvedValue; + } + } + return values; +} + export function buildChannelOnboardingAdapterFromSetupWizard(params: { plugin: ChannelSetupWizardPlugin; wizard: ChannelSetupWizard; @@ -178,66 +269,161 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { }); let next = cfg; - let credentialState = wizard.credential.inspect({ cfg: next, accountId }); - let resolvedCredentialValue = credentialState.resolvedValue?.trim() || undefined; - const allowEnv = wizard.credential.allowEnv?.({ cfg: next, accountId }) ?? false; - - const credentialResult = await runSingleChannelSecretStep({ + let credentialValues = collectCredentialValues({ + wizard, cfg: next, - prompter, - providerHint: wizard.credential.providerHint, - credentialLabel: wizard.credential.credentialLabel, - secretInputMode: options?.secretInputMode, - accountConfigured: credentialState.accountConfigured, - hasConfigToken: credentialState.hasConfiguredValue, - allowEnv, - envValue: credentialState.envValue, - envPrompt: wizard.credential.envPrompt, - keepPrompt: wizard.credential.keepPrompt, - inputPrompt: wizard.credential.inputPrompt, - preferredEnvVar: wizard.credential.preferredEnvVar, - onMissingConfigured: - wizard.credential.helpLines && wizard.credential.helpLines.length > 0 - ? async () => { - await prompter.note( - wizard.credential.helpLines!.join("\n"), - wizard.credential.helpTitle ?? wizard.credential.credentialLabel, - ); - } - : undefined, - applyUseEnv: async (currentCfg) => - applySetupInput({ - plugin, - cfg: currentCfg, - accountId, - input: { - [wizard.credential.inputKey]: undefined, - useEnv: true, - }, - }).cfg, - applySet: async (currentCfg, value, resolvedValue) => { - resolvedCredentialValue = resolvedValue; - return applySetupInput({ - plugin, - cfg: currentCfg, - accountId, - input: { - [wizard.credential.inputKey]: value, - useEnv: false, - }, - }).cfg; - }, + accountId, }); + let usedEnvShortcut = false; - next = credentialResult.cfg; - credentialState = wizard.credential.inspect({ cfg: next, accountId }); - resolvedCredentialValue = - credentialResult.resolvedValue?.trim() || - credentialState.resolvedValue?.trim() || - undefined; + if (wizard.envShortcut?.isAvailable({ cfg: next, accountId })) { + const useEnvShortcut = await prompter.confirm({ + message: wizard.envShortcut.prompt, + initialValue: true, + }); + if (useEnvShortcut) { + next = await wizard.envShortcut.apply({ cfg: next, accountId }); + credentialValues = collectCredentialValues({ + wizard, + cfg: next, + accountId, + }); + usedEnvShortcut = true; + } + } + + const shouldShowIntro = + !usedEnvShortcut && + (wizard.introNote?.shouldShow + ? await wizard.introNote.shouldShow({ + cfg: next, + accountId, + credentialValues, + }) + : Boolean(wizard.introNote)); + if (shouldShowIntro && wizard.introNote) { + await prompter.note(wizard.introNote.lines.join("\n"), wizard.introNote.title); + } + + if (!usedEnvShortcut) { + for (const credential of wizard.credentials) { + let credentialState = credential.inspect({ cfg: next, accountId }); + let resolvedCredentialValue = trimResolvedValue(credentialState.resolvedValue); + const allowEnv = credential.allowEnv?.({ cfg: next, accountId }) ?? false; + + const credentialResult = await runSingleChannelSecretStep({ + cfg: next, + prompter, + providerHint: credential.providerHint, + credentialLabel: credential.credentialLabel, + secretInputMode: options?.secretInputMode, + accountConfigured: credentialState.accountConfigured, + hasConfigToken: credentialState.hasConfiguredValue, + allowEnv, + envValue: credentialState.envValue, + envPrompt: credential.envPrompt, + keepPrompt: credential.keepPrompt, + inputPrompt: credential.inputPrompt, + preferredEnvVar: credential.preferredEnvVar, + onMissingConfigured: + credential.helpLines && credential.helpLines.length > 0 + ? async () => { + await prompter.note( + credential.helpLines!.join("\n"), + credential.helpTitle ?? credential.credentialLabel, + ); + } + : undefined, + applyUseEnv: async (currentCfg) => + credential.applyUseEnv + ? await credential.applyUseEnv({ + cfg: currentCfg, + accountId, + }) + : applySetupInput({ + plugin, + cfg: currentCfg, + accountId, + input: { + [credential.inputKey]: undefined, + useEnv: true, + }, + }).cfg, + applySet: async (currentCfg, value, resolvedValue) => { + resolvedCredentialValue = resolvedValue; + return credential.applySet + ? await credential.applySet({ + cfg: currentCfg, + accountId, + value, + resolvedValue, + }) + : applySetupInput({ + plugin, + cfg: currentCfg, + accountId, + input: { + [credential.inputKey]: value, + useEnv: false, + }, + }).cfg; + }, + }); + + next = credentialResult.cfg; + credentialState = credential.inspect({ cfg: next, accountId }); + resolvedCredentialValue = + trimResolvedValue(credentialResult.resolvedValue) || + trimResolvedValue(credentialState.resolvedValue); + if (resolvedCredentialValue) { + credentialValues[credential.inputKey] = resolvedCredentialValue; + } else { + delete credentialValues[credential.inputKey]; + } + } + } + + if (wizard.groupAccess) { + const access = wizard.groupAccess; + if (access.helpLines && access.helpLines.length > 0) { + await prompter.note(access.helpLines.join("\n"), access.helpTitle ?? access.label); + } + next = await configureChannelAccessWithAllowlist({ + cfg: next, + prompter, + label: access.label, + currentPolicy: access.currentPolicy({ cfg: next, accountId }), + currentEntries: access.currentEntries({ cfg: next, accountId }), + placeholder: access.placeholder, + updatePrompt: access.updatePrompt({ cfg: next, accountId }), + setPolicy: (currentCfg, policy) => + access.setPolicy({ + cfg: currentCfg, + accountId, + policy, + }), + resolveAllowlist: async ({ cfg: currentCfg, entries }) => + await access.resolveAllowlist({ + cfg: currentCfg, + accountId, + credentialValues, + entries, + prompter, + }), + applyAllowlist: ({ cfg: currentCfg, resolved }) => + access.applyAllowlist({ + cfg: currentCfg, + accountId, + resolved, + }), + }); + } if (forceAllowFrom && wizard.allowFrom) { const allowFrom = wizard.allowFrom; + const allowFromCredentialValue = trimResolvedValue( + credentialValues[allowFrom.credentialInputKey ?? wizard.credentials[0]?.inputKey], + ); if (allowFrom.helpLines && allowFrom.helpLines.length > 0) { await prompter.note( allowFrom.helpLines.join("\n"), @@ -252,7 +438,7 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { const unique = await promptResolvedAllowFrom({ prompter, existing: existingAllowFrom, - token: resolvedCredentialValue, + token: allowFromCredentialValue, message: allowFrom.message, placeholder: allowFrom.placeholder, label: allowFrom.helpTitle ?? `${plugin.meta.label} allowlist`, @@ -263,7 +449,7 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { allowFrom.resolveEntries({ cfg: next, accountId, - credentialValue: resolvedCredentialValue, + credentialValues, entries, }), }); From bb160ebe89ab7e0f1e47fc3090dbb1f481c8a975 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 16:47:47 -0700 Subject: [PATCH 046/943] refactor: move discord and slack to setup wizard --- extensions/discord/src/channel.ts | 73 +--- extensions/discord/src/setup-surface.ts | 423 +++++++++++++++++++ extensions/slack/src/channel.ts | 79 +--- extensions/slack/src/setup-surface.ts | 531 ++++++++++++++++++++++++ 4 files changed, 960 insertions(+), 146 deletions(-) create mode 100644 extensions/discord/src/setup-surface.ts create mode 100644 extensions/slack/src/setup-surface.ts diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index dff426ab2e4..0123553fcb7 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -7,14 +7,12 @@ import { formatAllowFromLowercase, } from "openclaw/plugin-sdk/compat"; import { - applyAccountNameToChannelSection, buildComputedAccountStatusSnapshot, buildChannelConfigSchema, buildTokenChannelStatusSummary, collectDiscordAuditChannelIds, collectDiscordStatusIssues, DEFAULT_ACCOUNT_ID, - discordOnboardingAdapter, DiscordConfigSchema, getChatChannelMeta, inspectDiscordAccount, @@ -22,8 +20,6 @@ import { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig, looksLikeDiscordTargetId, - migrateBaseNameToDefaultAccount, - normalizeAccountId, normalizeDiscordMessagingTarget, normalizeDiscordOutboundTarget, PAIRING_APPROVED_MESSAGE, @@ -39,6 +35,7 @@ import { } from "openclaw/plugin-sdk/discord"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { getDiscordRuntime } from "./runtime.js"; +import { discordSetupAdapter, discordSetupWizard } from "./setup-surface.js"; type DiscordSendFn = ReturnType< typeof getDiscordRuntime @@ -81,7 +78,7 @@ export const discordPlugin: ChannelPlugin = { meta: { ...meta, }, - onboarding: discordOnboardingAdapter, + setupWizard: discordSetupWizard, pairing: { idLabel: "discordUserId", normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""), @@ -233,71 +230,7 @@ export const discordPlugin: ChannelPlugin = { }, }, actions: discordMessageActions, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: "discord", - 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: "discord", - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "discord", - }) - : 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 } : {}), - }, - }, - }, - }, - }; - }, - }, + setup: discordSetupAdapter, outbound: { deliveryMode: "direct", chunker: null, diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts new file mode 100644 index 00000000000..eb4db7eda65 --- /dev/null +++ b/extensions/discord/src/setup-surface.ts @@ -0,0 +1,423 @@ +import type { + ChannelOnboardingAdapter, + ChannelOnboardingDmPolicy, +} from "../../../src/channels/plugins/onboarding-types.js"; +import { + noteChannelLookupFailure, + noteChannelLookupSummary, + parseMentionOrPrefixedId, + patchChannelConfigForAccount, + promptLegacyChannelAllowFrom, + resolveOnboardingAccountId, + setLegacyChannelDmPolicyWithAllowFrom, + setOnboardingChannelEnabled, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import { + buildChannelOnboardingAdapterFromSetupWizard, + type ChannelSetupWizard, +} from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { getChatChannelMeta } from "../../../src/channels/registry.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DiscordGuildEntry } from "../../../src/config/types.discord.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { inspectDiscordAccount } from "./account-inspect.js"; +import { + listDiscordAccountIds, + resolveDefaultDiscordAccountId, + resolveDiscordAccount, +} from "./accounts.js"; +import { normalizeDiscordSlug } from "./monitor/allow-list.js"; +import { + resolveDiscordChannelAllowlist, + type DiscordChannelResolution, +} from "./resolve-channels.js"; +import { resolveDiscordUserAllowlist } from "./resolve-users.js"; + +const channel = "discord" as const; + +const DISCORD_TOKEN_HELP_LINES = [ + "1) Discord Developer Portal -> Applications -> New Application", + "2) Bot -> Add Bot -> Reset Token -> copy token", + "3) OAuth2 -> URL Generator -> scope 'bot' -> invite to your server", + "Tip: enable Message Content Intent if you need message text. (Bot -> Privileged Gateway Intents -> Message Content Intent)", + `Docs: ${formatDocsLink("/discord", "discord")}`, +]; + +function setDiscordGuildChannelAllowlist( + cfg: OpenClawConfig, + accountId: string, + entries: Array<{ + guildKey: string; + channelKey?: string; + }>, +): OpenClawConfig { + const baseGuilds = + accountId === DEFAULT_ACCOUNT_ID + ? (cfg.channels?.discord?.guilds ?? {}) + : (cfg.channels?.discord?.accounts?.[accountId]?.guilds ?? {}); + const guilds: Record = { ...baseGuilds }; + for (const entry of entries) { + const guildKey = entry.guildKey || "*"; + const existing = guilds[guildKey] ?? {}; + if (entry.channelKey) { + const channels = { ...existing.channels }; + channels[entry.channelKey] = { allow: true }; + guilds[guildKey] = { ...existing, channels }; + } else { + guilds[guildKey] = existing; + } + } + return patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { guilds }, + }); +} + +function parseDiscordAllowFromId(value: string): string | null { + return parseMentionOrPrefixedId({ + value, + mentionPattern: /^<@!?(\d+)>$/, + prefixPattern: /^(user:|discord:)/i, + idPattern: /^\d+$/, + }); +} + +async function resolveDiscordAllowFromEntries(params: { token?: string; entries: string[] }) { + if (!params.token?.trim()) { + return params.entries.map((input) => ({ + input, + resolved: false, + id: null, + })); + } + const resolved = await resolveDiscordUserAllowlist({ + token: params.token, + entries: params.entries, + }); + return resolved.map((entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.id ?? null, + })); +} + +async function promptDiscordAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultDiscordAccountId(params.cfg), + }); + const resolved = resolveDiscordAccount({ cfg: params.cfg, accountId }); + return promptLegacyChannelAllowFrom({ + cfg: params.cfg, + channel, + prompter: params.prompter, + existing: resolved.config.allowFrom ?? resolved.config.dm?.allowFrom ?? [], + token: resolved.token, + noteTitle: "Discord allowlist", + noteLines: [ + "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", + parseId: parseDiscordAllowFromId, + invalidWithoutTokenNote: "Bot token missing; use numeric user ids (or mention form) only.", + resolveEntries: ({ token, entries }) => + resolveDiscordUserAllowlist({ + token, + entries, + }), + }); +} + +const discordDmPolicy: ChannelOnboardingDmPolicy = { + 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, +}; + +export const discordSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "DISCORD_BOT_TOKEN can only be used for the default account."; + } + if (!input.useEnv && !input.token) { + return "Discord requires token (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + discord: { + ...next.channels?.discord, + enabled: true, + ...(input.useEnv ? {} : input.token ? { token: input.token } : {}), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + discord: { + ...next.channels?.discord, + enabled: true, + accounts: { + ...next.channels?.discord?.accounts, + [accountId]: { + ...next.channels?.discord?.accounts?.[accountId], + enabled: true, + ...(input.token ? { token: input.token } : {}), + }, + }, + }, + }, + }; + }, +}; + +export const discordSetupWizard: ChannelSetupWizard = { + channel, + status: { + 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, + }; + }, + }, + ], + 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, + 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: { + 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) => setOnboardingChannelEnabled(cfg, channel, false), +}; + +const discordSetupPlugin = { + id: channel, + meta: { + ...getChatChannelMeta(channel), + quickstartAllowFrom: true, + }, + config: { + listAccountIds: listDiscordAccountIds, + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => + resolveDiscordAccount({ cfg, accountId }), + resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => { + const resolved = resolveDiscordAccount({ cfg, accountId }); + return resolved.config.allowFrom ?? resolved.config.dm?.allowFrom; + }, + }, + setup: discordSetupAdapter, +} as const; + +export const discordOnboardingAdapter: ChannelOnboardingAdapter = + buildChannelOnboardingAdapterFromSetupWizard({ + plugin: discordSetupPlugin, + wizard: discordSetupWizard, + }); diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 04b46357db4..5903e5755b2 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -7,7 +7,6 @@ import { formatAllowFromLowercase, } from "openclaw/plugin-sdk/compat"; import { - applyAccountNameToChannelSection, buildComputedAccountStatusSnapshot, buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, @@ -20,8 +19,6 @@ import { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig, looksLikeSlackTargetId, - migrateBaseNameToDefaultAccount, - normalizeAccountId, normalizeSlackMessagingTarget, PAIRING_APPROVED_MESSAGE, projectCredentialSnapshotFields, @@ -33,7 +30,6 @@ import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, buildSlackThreadingToolContext, - slackOnboardingAdapter, SlackConfigSchema, type ChannelPlugin, type ResolvedSlackAccount, @@ -41,6 +37,7 @@ import { import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { getSlackRuntime } from "./runtime.js"; +import { slackSetupAdapter, slackSetupWizard } from "./setup-surface.js"; const meta = getChatChannelMeta("slack"); @@ -115,7 +112,7 @@ export const slackPlugin: ChannelPlugin = { ...meta, preferSessionLookupForAnnounceTarget: true, }, - onboarding: slackOnboardingAdapter, + setupWizard: slackSetupWizard, pairing: { idLabel: "slackUserId", normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""), @@ -297,77 +294,7 @@ export const slackPlugin: ChannelPlugin = { await getSlackRuntime().channel.slack.handleSlackAction(action, cfg, toolContext), }), }, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: "slack", - 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: "slack", - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "slack", - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - slack: { - ...next.channels?.slack, - enabled: true, - ...(input.useEnv - ? {} - : { - ...(input.botToken ? { botToken: input.botToken } : {}), - ...(input.appToken ? { appToken: input.appToken } : {}), - }), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - slack: { - ...next.channels?.slack, - enabled: true, - accounts: { - ...next.channels?.slack?.accounts, - [accountId]: { - ...next.channels?.slack?.accounts?.[accountId], - enabled: true, - ...(input.botToken ? { botToken: input.botToken } : {}), - ...(input.appToken ? { appToken: input.appToken } : {}), - }, - }, - }, - }, - }; - }, - }, + setup: slackSetupAdapter, outbound: { deliveryMode: "direct", chunker: null, diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts new file mode 100644 index 00000000000..7d90bba937c --- /dev/null +++ b/extensions/slack/src/setup-surface.ts @@ -0,0 +1,531 @@ +import type { + ChannelOnboardingAdapter, + ChannelOnboardingDmPolicy, +} from "../../../src/channels/plugins/onboarding-types.js"; +import { + noteChannelLookupFailure, + noteChannelLookupSummary, + parseMentionOrPrefixedId, + patchChannelConfigForAccount, + promptLegacyChannelAllowFrom, + resolveOnboardingAccountId, + setAccountGroupPolicyForChannel, + setLegacyChannelDmPolicyWithAllowFrom, + setOnboardingChannelEnabled, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import { + buildChannelOnboardingAdapterFromSetupWizard, + type ChannelSetupWizard, + type ChannelSetupWizardAllowFromEntry, +} from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { getChatChannelMeta } from "../../../src/channels/registry.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { inspectSlackAccount } from "./account-inspect.js"; +import { + listSlackAccountIds, + resolveDefaultSlackAccountId, + resolveSlackAccount, + type ResolvedSlackAccount, +} from "./accounts.js"; +import { resolveSlackChannelAllowlist } from "./resolve-channels.js"; +import { resolveSlackUserAllowlist } from "./resolve-users.js"; + +const channel = "slack" as const; + +function buildSlackManifest(botName: string) { + const safeName = botName.trim() || "OpenClaw"; + const manifest = { + display_information: { + name: safeName, + description: `${safeName} connector for OpenClaw`, + }, + features: { + bot_user: { + display_name: safeName, + always_online: false, + }, + app_home: { + messages_tab_enabled: true, + messages_tab_read_only_enabled: false, + }, + slash_commands: [ + { + command: "/openclaw", + description: "Send a message to OpenClaw", + should_escape: false, + }, + ], + }, + oauth_config: { + scopes: { + bot: [ + "chat:write", + "channels:history", + "channels:read", + "groups:history", + "im:history", + "mpim:history", + "users:read", + "app_mentions:read", + "reactions:read", + "reactions:write", + "pins:read", + "pins:write", + "emoji:read", + "commands", + "files:read", + "files:write", + ], + }, + }, + settings: { + socket_mode_enabled: true, + event_subscriptions: { + bot_events: [ + "app_mention", + "message.channels", + "message.groups", + "message.im", + "message.mpim", + "reaction_added", + "reaction_removed", + "member_joined_channel", + "member_left_channel", + "channel_rename", + "pin_added", + "pin_removed", + ], + }, + }, + }; + return JSON.stringify(manifest, null, 2); +} + +function buildSlackSetupLines(botName = "OpenClaw"): string[] { + return [ + "1) Slack API -> Create App -> From scratch or From manifest (with the JSON below)", + "2) Add Socket Mode + enable it to get the app-level token (xapp-...)", + "3) Install App to workspace to get the xoxb- bot token", + "4) Enable Event Subscriptions (socket) for message events", + "5) App Home -> enable the Messages tab for DMs", + "Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.", + `Docs: ${formatDocsLink("/slack", "slack")}`, + "", + "Manifest (JSON):", + buildSlackManifest(botName), + ]; +} + +function setSlackChannelAllowlist( + cfg: OpenClawConfig, + accountId: string, + channelKeys: string[], +): OpenClawConfig { + const channels = Object.fromEntries(channelKeys.map((key) => [key, { allow: true }])); + return patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { channels }, + }); +} + +function enableSlackAccount(cfg: OpenClawConfig, accountId: string): OpenClawConfig { + return patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { enabled: true }, + }); +} + +async function resolveSlackAllowFromEntries(params: { + token?: string; + entries: string[]; +}): Promise { + if (!params.token?.trim()) { + return params.entries.map((input) => ({ + input, + resolved: false, + id: null, + })); + } + const resolved = await resolveSlackUserAllowlist({ + token: params.token, + entries: params.entries, + }); + return resolved.map((entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.id ?? null, + })); +} + +async function promptSlackAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultSlackAccountId(params.cfg), + }); + const resolved = resolveSlackAccount({ cfg: params.cfg, accountId }); + const token = resolved.userToken ?? resolved.botToken ?? ""; + const existing = + params.cfg.channels?.slack?.allowFrom ?? params.cfg.channels?.slack?.dm?.allowFrom ?? []; + const parseId = (value: string) => + parseMentionOrPrefixedId({ + value, + mentionPattern: /^<@([A-Z0-9]+)>$/i, + prefixPattern: /^(slack:|user:)/i, + idPattern: /^[A-Z][A-Z0-9]+$/i, + normalizeId: (id) => id.toUpperCase(), + }); + + return promptLegacyChannelAllowFrom({ + cfg: params.cfg, + channel, + prompter: params.prompter, + existing, + token, + noteTitle: "Slack allowlist", + noteLines: [ + "Allowlist Slack DMs by username (we resolve to user ids).", + "Examples:", + "- U12345678", + "- @alice", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/slack", "slack")}`, + ], + message: "Slack allowFrom (usernames or ids)", + placeholder: "@alice, U12345678", + parseId, + invalidWithoutTokenNote: "Slack token missing; use user ids (or mention form) only.", + resolveEntries: ({ token, entries }) => + resolveSlackUserAllowlist({ + token, + entries, + }), + }); +} + +const slackDmPolicy: ChannelOnboardingDmPolicy = { + 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, +}; + +function isSlackAccountConfigured(account: ResolvedSlackAccount): boolean { + const hasConfiguredBotToken = + Boolean(account.botToken?.trim()) || hasConfiguredSecretInput(account.config.botToken); + const hasConfiguredAppToken = + Boolean(account.appToken?.trim()) || hasConfiguredSecretInput(account.config.appToken); + return hasConfiguredBotToken && hasConfiguredAppToken; +} + +export const slackSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "Slack env tokens can only be used for the default account."; + } + if (!input.useEnv && (!input.botToken || !input.appToken)) { + return "Slack requires --bot-token and --app-token (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + slack: { + ...next.channels?.slack, + enabled: true, + ...(input.useEnv + ? {} + : { + ...(input.botToken ? { botToken: input.botToken } : {}), + ...(input.appToken ? { appToken: input.appToken } : {}), + }), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + slack: { + ...next.channels?.slack, + enabled: true, + accounts: { + ...next.channels?.slack?.accounts, + [accountId]: { + ...next.channels?.slack?.accounts?.[accountId], + enabled: true, + ...(input.botToken ? { botToken: input.botToken } : {}), + ...(input.appToken ? { appToken: input.appToken } : {}), + }, + }, + }, + }, + }; + }, +}; + +export 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 }) => + !isSlackAccountConfigured(resolveSlackAccount({ cfg, accountId })), + }, + envShortcut: { + prompt: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?", + preferredEnvVar: "SLACK_BOT_TOKEN", + isAvailable: ({ cfg, accountId }) => + accountId === DEFAULT_ACCOUNT_ID && + Boolean(process.env.SLACK_BOT_TOKEN?.trim()) && + Boolean(process.env.SLACK_APP_TOKEN?.trim()) && + !isSlackAccountConfigured(resolveSlackAccount({ cfg, accountId })), + apply: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId), + }, + credentials: [ + { + inputKey: "botToken", + providerHint: "slack-bot", + credentialLabel: "Slack bot token", + preferredEnvVar: "SLACK_BOT_TOKEN", + envPrompt: "SLACK_BOT_TOKEN detected. Use env var?", + keepPrompt: "Slack bot token already configured. Keep it?", + inputPrompt: "Enter Slack bot token (xoxb-...)", + allowEnv: ({ accountId }) => accountId === 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, + }); + 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; + }, + applyAllowlist: ({ cfg, accountId, resolved }) => + setSlackChannelAllowlist(cfg, accountId, resolved as string[]), + }, + disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), +}; + +const slackSetupPlugin = { + id: channel, + meta: { + ...getChatChannelMeta(channel), + quickstartAllowFrom: true, + }, + config: { + listAccountIds: listSlackAccountIds, + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => + resolveSlackAccount({ cfg, accountId }), + resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => + resolveSlackAccount({ cfg, accountId }).dm?.allowFrom, + }, + setup: slackSetupAdapter, +} as const; + +export const slackOnboardingAdapter: ChannelOnboardingAdapter = + buildChannelOnboardingAdapterFromSetupWizard({ + plugin: slackSetupPlugin, + wizard: slackSetupWizard, + }); From 5a68e8261e2d0f91def4392de7308a5637bcaa07 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 16:47:54 -0700 Subject: [PATCH 047/943] refactor: drop onboarding adapter sdk exports --- extensions/discord/src/onboarding.ts | 319 ---------------------- extensions/slack/src/onboarding.ts | 363 -------------------------- extensions/telegram/src/onboarding.ts | 6 - src/plugin-sdk/discord.ts | 5 +- src/plugin-sdk/index.ts | 12 +- src/plugin-sdk/slack.ts | 2 +- src/plugin-sdk/subpaths.test.ts | 8 +- src/plugin-sdk/telegram.ts | 5 +- 8 files changed, 24 insertions(+), 696 deletions(-) delete mode 100644 extensions/discord/src/onboarding.ts delete mode 100644 extensions/slack/src/onboarding.ts delete mode 100644 extensions/telegram/src/onboarding.ts diff --git a/extensions/discord/src/onboarding.ts b/extensions/discord/src/onboarding.ts deleted file mode 100644 index 061f4614241..00000000000 --- a/extensions/discord/src/onboarding.ts +++ /dev/null @@ -1,319 +0,0 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../../../src/channels/plugins/onboarding-types.js"; -import { configureChannelAccessWithAllowlist } from "../../../src/channels/plugins/onboarding/channel-access-configure.js"; -import { - applySingleTokenPromptResult, - noteChannelLookupFailure, - noteChannelLookupSummary, - parseMentionOrPrefixedId, - patchChannelConfigForAccount, - promptLegacyChannelAllowFrom, - resolveAccountIdForConfigure, - resolveOnboardingAccountId, - runSingleChannelSecretStep, - setAccountGroupPolicyForChannel, - setLegacyChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { DiscordGuildEntry } from "../../../src/config/types.discord.js"; -import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; -import { inspectDiscordAccount } from "./account-inspect.js"; -import { - listDiscordAccountIds, - resolveDefaultDiscordAccountId, - resolveDiscordAccount, -} from "./accounts.js"; -import { normalizeDiscordSlug } from "./monitor/allow-list.js"; -import { - resolveDiscordChannelAllowlist, - type DiscordChannelResolution, -} from "./resolve-channels.js"; -import { resolveDiscordUserAllowlist } from "./resolve-users.js"; - -const channel = "discord" as const; - -async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise { - await prompter.note( - [ - "1) Discord Developer Portal → Applications → New Application", - "2) Bot → Add Bot → Reset Token → copy token", - "3) OAuth2 → URL Generator → scope 'bot' → invite to your server", - "Tip: enable Message Content Intent if you need message text. (Bot → Privileged Gateway Intents → Message Content Intent)", - `Docs: ${formatDocsLink("/discord", "discord")}`, - ].join("\n"), - "Discord bot token", - ); -} - -function setDiscordGuildChannelAllowlist( - cfg: OpenClawConfig, - accountId: string, - entries: Array<{ - guildKey: string; - channelKey?: string; - }>, -): OpenClawConfig { - const baseGuilds = - accountId === DEFAULT_ACCOUNT_ID - ? (cfg.channels?.discord?.guilds ?? {}) - : (cfg.channels?.discord?.accounts?.[accountId]?.guilds ?? {}); - const guilds: Record = { ...baseGuilds }; - for (const entry of entries) { - const guildKey = entry.guildKey || "*"; - const existing = guilds[guildKey] ?? {}; - if (entry.channelKey) { - const channels = { ...existing.channels }; - channels[entry.channelKey] = { allow: true }; - guilds[guildKey] = { ...existing, channels }; - } else { - guilds[guildKey] = existing; - } - } - return patchChannelConfigForAccount({ - cfg, - channel: "discord", - accountId, - patch: { guilds }, - }); -} - -async function promptDiscordAllowFrom(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - const accountId = resolveOnboardingAccountId({ - accountId: params.accountId, - defaultAccountId: resolveDefaultDiscordAccountId(params.cfg), - }); - const resolved = resolveDiscordAccount({ cfg: params.cfg, accountId }); - const token = resolved.token; - const existing = - params.cfg.channels?.discord?.allowFrom ?? params.cfg.channels?.discord?.dm?.allowFrom ?? []; - const parseId = (value: string) => - parseMentionOrPrefixedId({ - value, - mentionPattern: /^<@!?(\d+)>$/, - prefixPattern: /^(user:|discord:)/i, - idPattern: /^\d+$/, - }); - - return promptLegacyChannelAllowFrom({ - cfg: params.cfg, - channel: "discord", - prompter: params.prompter, - existing, - token, - noteTitle: "Discord allowlist", - noteLines: [ - "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", - parseId, - invalidWithoutTokenNote: "Bot token missing; use numeric user ids (or mention form) only.", - resolveEntries: ({ token, entries }) => - resolveDiscordUserAllowlist({ - token, - entries, - }), - }); -} - -const dmPolicy: ChannelOnboardingDmPolicy = { - 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: "discord", - dmPolicy: policy, - }), - promptAllowFrom: promptDiscordAllowFrom, -}; - -export const discordOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const configured = listDiscordAccountIds(cfg).some((accountId) => { - const account = inspectDiscordAccount({ cfg, accountId }); - return account.configured; - }); - return { - channel, - configured, - statusLines: [`Discord: ${configured ? "configured" : "needs token"}`], - selectionHint: configured ? "configured" : "needs token", - quickstartScore: configured ? 2 : 1, - }; - }, - configure: async ({ cfg, prompter, options, accountOverrides, shouldPromptAccountIds }) => { - const defaultDiscordAccountId = resolveDefaultDiscordAccountId(cfg); - const discordAccountId = await resolveAccountIdForConfigure({ - cfg, - prompter, - label: "Discord", - accountOverride: accountOverrides.discord, - shouldPromptAccountIds, - listAccountIds: listDiscordAccountIds, - defaultAccountId: defaultDiscordAccountId, - }); - - let next = cfg; - const resolvedAccount = resolveDiscordAccount({ - cfg: next, - accountId: discordAccountId, - }); - const allowEnv = discordAccountId === DEFAULT_ACCOUNT_ID; - const tokenStep = await runSingleChannelSecretStep({ - cfg: next, - prompter, - providerHint: "discord", - credentialLabel: "Discord bot token", - secretInputMode: options?.secretInputMode, - accountConfigured: Boolean(resolvedAccount.token), - hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.token), - allowEnv, - envValue: process.env.DISCORD_BOT_TOKEN, - envPrompt: "DISCORD_BOT_TOKEN detected. Use env var?", - keepPrompt: "Discord token already configured. Keep it?", - inputPrompt: "Enter Discord bot token", - preferredEnvVar: allowEnv ? "DISCORD_BOT_TOKEN" : undefined, - onMissingConfigured: async () => await noteDiscordTokenHelp(prompter), - applyUseEnv: async (cfg) => - applySingleTokenPromptResult({ - cfg, - channel: "discord", - accountId: discordAccountId, - tokenPatchKey: "token", - tokenResult: { useEnv: true, token: null }, - }), - applySet: async (cfg, value) => - applySingleTokenPromptResult({ - cfg, - channel: "discord", - accountId: discordAccountId, - tokenPatchKey: "token", - tokenResult: { useEnv: false, token: value }, - }), - }); - next = tokenStep.cfg; - - const currentEntries = Object.entries(resolvedAccount.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}`); - }, - ); - next = await configureChannelAccessWithAllowlist({ - cfg: next, - prompter, - label: "Discord channels", - currentPolicy: resolvedAccount.config.groupPolicy ?? "allowlist", - currentEntries, - placeholder: "My Server/#general, guildId/channelId, #support", - updatePrompt: Boolean(resolvedAccount.config.guilds), - setPolicy: (cfg, policy) => - setAccountGroupPolicyForChannel({ - cfg, - channel: "discord", - accountId: discordAccountId, - groupPolicy: policy, - }), - resolveAllowlist: async ({ cfg, entries }) => { - const accountWithTokens = resolveDiscordAccount({ - cfg, - accountId: discordAccountId, - }); - let resolved: DiscordChannelResolution[] = entries.map((input) => ({ - input, - resolved: false, - })); - const activeToken = accountWithTokens.token || tokenStep.resolvedValue || ""; - if (activeToken && entries.length > 0) { - try { - resolved = await resolveDiscordChannelAllowlist({ - token: activeToken, - 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 (err) { - await noteChannelLookupFailure({ - prompter, - label: "Discord channels", - error: err, - }); - } - } - return resolved; - }, - applyAllowlist: ({ cfg, resolved }) => { - const allowlistEntries: Array<{ guildKey: string; channelKey?: string }> = []; - for (const entry of resolved) { - 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, discordAccountId, allowlistEntries); - }, - }); - - return { cfg: next, accountId: discordAccountId }; - }, - dmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), -}; diff --git a/extensions/slack/src/onboarding.ts b/extensions/slack/src/onboarding.ts deleted file mode 100644 index 552c8a9d19b..00000000000 --- a/extensions/slack/src/onboarding.ts +++ /dev/null @@ -1,363 +0,0 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../../../src/channels/plugins/onboarding-types.js"; -import { configureChannelAccessWithAllowlist } from "../../../src/channels/plugins/onboarding/channel-access-configure.js"; -import { - noteChannelLookupFailure, - noteChannelLookupSummary, - parseMentionOrPrefixedId, - patchChannelConfigForAccount, - promptLegacyChannelAllowFrom, - resolveAccountIdForConfigure, - resolveOnboardingAccountId, - runSingleChannelSecretStep, - setAccountGroupPolicyForChannel, - setLegacyChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; -import { inspectSlackAccount } from "./account-inspect.js"; -import { - listSlackAccountIds, - resolveDefaultSlackAccountId, - resolveSlackAccount, -} from "./accounts.js"; -import { resolveSlackChannelAllowlist } from "./resolve-channels.js"; -import { resolveSlackUserAllowlist } from "./resolve-users.js"; - -const channel = "slack" as const; - -function buildSlackManifest(botName: string) { - const safeName = botName.trim() || "OpenClaw"; - const manifest = { - display_information: { - name: safeName, - description: `${safeName} connector for OpenClaw`, - }, - features: { - bot_user: { - display_name: safeName, - always_online: false, - }, - app_home: { - messages_tab_enabled: true, - messages_tab_read_only_enabled: false, - }, - slash_commands: [ - { - command: "/openclaw", - description: "Send a message to OpenClaw", - should_escape: false, - }, - ], - }, - oauth_config: { - scopes: { - bot: [ - "chat:write", - "channels:history", - "channels:read", - "groups:history", - "im:history", - "mpim:history", - "users:read", - "app_mentions:read", - "reactions:read", - "reactions:write", - "pins:read", - "pins:write", - "emoji:read", - "commands", - "files:read", - "files:write", - ], - }, - }, - settings: { - socket_mode_enabled: true, - event_subscriptions: { - bot_events: [ - "app_mention", - "message.channels", - "message.groups", - "message.im", - "message.mpim", - "reaction_added", - "reaction_removed", - "member_joined_channel", - "member_left_channel", - "channel_rename", - "pin_added", - "pin_removed", - ], - }, - }, - }; - return JSON.stringify(manifest, null, 2); -} - -async function noteSlackTokenHelp(prompter: WizardPrompter, botName: string): Promise { - const manifest = buildSlackManifest(botName); - await prompter.note( - [ - "1) Slack API → Create App → From scratch or From manifest (with the JSON below)", - "2) Add Socket Mode + enable it to get the app-level token (xapp-...)", - "3) Install App to workspace to get the xoxb- bot token", - "4) Enable Event Subscriptions (socket) for message events", - "5) App Home → enable the Messages tab for DMs", - "Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.", - `Docs: ${formatDocsLink("/slack", "slack")}`, - "", - "Manifest (JSON):", - manifest, - ].join("\n"), - "Slack socket mode tokens", - ); -} - -function setSlackChannelAllowlist( - cfg: OpenClawConfig, - accountId: string, - channelKeys: string[], -): OpenClawConfig { - const channels = Object.fromEntries(channelKeys.map((key) => [key, { allow: true }])); - return patchChannelConfigForAccount({ - cfg, - channel: "slack", - accountId, - patch: { channels }, - }); -} - -async function promptSlackAllowFrom(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - const accountId = resolveOnboardingAccountId({ - accountId: params.accountId, - defaultAccountId: resolveDefaultSlackAccountId(params.cfg), - }); - const resolved = resolveSlackAccount({ cfg: params.cfg, accountId }); - const token = resolved.userToken ?? resolved.botToken ?? ""; - const existing = - params.cfg.channels?.slack?.allowFrom ?? params.cfg.channels?.slack?.dm?.allowFrom ?? []; - const parseId = (value: string) => - parseMentionOrPrefixedId({ - value, - mentionPattern: /^<@([A-Z0-9]+)>$/i, - prefixPattern: /^(slack:|user:)/i, - idPattern: /^[A-Z][A-Z0-9]+$/i, - normalizeId: (id) => id.toUpperCase(), - }); - - return promptLegacyChannelAllowFrom({ - cfg: params.cfg, - channel: "slack", - prompter: params.prompter, - existing, - token, - noteTitle: "Slack allowlist", - noteLines: [ - "Allowlist Slack DMs by username (we resolve to user ids).", - "Examples:", - "- U12345678", - "- @alice", - "Multiple entries: comma-separated.", - `Docs: ${formatDocsLink("/slack", "slack")}`, - ], - message: "Slack allowFrom (usernames or ids)", - placeholder: "@alice, U12345678", - parseId, - invalidWithoutTokenNote: "Slack token missing; use user ids (or mention form) only.", - resolveEntries: ({ token, entries }) => - resolveSlackUserAllowlist({ - token, - entries, - }), - }); -} - -const dmPolicy: ChannelOnboardingDmPolicy = { - 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: "slack", - dmPolicy: policy, - }), - promptAllowFrom: promptSlackAllowFrom, -}; - -export const slackOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const configured = listSlackAccountIds(cfg).some((accountId) => { - const account = inspectSlackAccount({ cfg, accountId }); - return account.configured; - }); - return { - channel, - configured, - statusLines: [`Slack: ${configured ? "configured" : "needs tokens"}`], - selectionHint: configured ? "configured" : "needs tokens", - quickstartScore: configured ? 2 : 1, - }; - }, - configure: async ({ cfg, prompter, options, accountOverrides, shouldPromptAccountIds }) => { - const defaultSlackAccountId = resolveDefaultSlackAccountId(cfg); - const slackAccountId = await resolveAccountIdForConfigure({ - cfg, - prompter, - label: "Slack", - accountOverride: accountOverrides.slack, - shouldPromptAccountIds, - listAccountIds: listSlackAccountIds, - defaultAccountId: defaultSlackAccountId, - }); - - let next = cfg; - const resolvedAccount = resolveSlackAccount({ - cfg: next, - accountId: slackAccountId, - }); - const hasConfiguredBotToken = hasConfiguredSecretInput(resolvedAccount.config.botToken); - const hasConfiguredAppToken = hasConfiguredSecretInput(resolvedAccount.config.appToken); - const hasConfigTokens = hasConfiguredBotToken && hasConfiguredAppToken; - const accountConfigured = - Boolean(resolvedAccount.botToken && resolvedAccount.appToken) || hasConfigTokens; - const allowEnv = slackAccountId === DEFAULT_ACCOUNT_ID; - let resolvedBotTokenForAllowlist = resolvedAccount.botToken; - const slackBotName = String( - await prompter.text({ - message: "Slack bot display name (used for manifest)", - initialValue: "OpenClaw", - }), - ).trim(); - if (!accountConfigured) { - await noteSlackTokenHelp(prompter, slackBotName); - } - const botTokenStep = await runSingleChannelSecretStep({ - cfg: next, - prompter, - providerHint: "slack-bot", - credentialLabel: "Slack bot token", - secretInputMode: options?.secretInputMode, - accountConfigured: Boolean(resolvedAccount.botToken) || hasConfiguredBotToken, - hasConfigToken: hasConfiguredBotToken, - allowEnv, - envValue: process.env.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-...)", - preferredEnvVar: allowEnv ? "SLACK_BOT_TOKEN" : undefined, - applySet: async (cfg, value) => - patchChannelConfigForAccount({ - cfg, - channel: "slack", - accountId: slackAccountId, - patch: { botToken: value }, - }), - }); - next = botTokenStep.cfg; - if (botTokenStep.resolvedValue) { - resolvedBotTokenForAllowlist = botTokenStep.resolvedValue; - } - - const appTokenStep = await runSingleChannelSecretStep({ - cfg: next, - prompter, - providerHint: "slack-app", - credentialLabel: "Slack app token", - secretInputMode: options?.secretInputMode, - accountConfigured: Boolean(resolvedAccount.appToken) || hasConfiguredAppToken, - hasConfigToken: hasConfiguredAppToken, - allowEnv, - envValue: process.env.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-...)", - preferredEnvVar: allowEnv ? "SLACK_APP_TOKEN" : undefined, - applySet: async (cfg, value) => - patchChannelConfigForAccount({ - cfg, - channel: "slack", - accountId: slackAccountId, - patch: { appToken: value }, - }), - }); - next = appTokenStep.cfg; - - next = await configureChannelAccessWithAllowlist({ - cfg: next, - prompter, - label: "Slack channels", - currentPolicy: resolvedAccount.config.groupPolicy ?? "allowlist", - currentEntries: Object.entries(resolvedAccount.config.channels ?? {}) - .filter(([, value]) => value?.allow !== false && value?.enabled !== false) - .map(([key]) => key), - placeholder: "#general, #private, C123", - updatePrompt: Boolean(resolvedAccount.config.channels), - setPolicy: (cfg, policy) => - setAccountGroupPolicyForChannel({ - cfg, - channel: "slack", - accountId: slackAccountId, - groupPolicy: policy, - }), - resolveAllowlist: async ({ cfg, entries }) => { - let keys = entries; - const accountWithTokens = resolveSlackAccount({ - cfg, - accountId: slackAccountId, - }); - const activeBotToken = accountWithTokens.botToken || resolvedBotTokenForAllowlist || ""; - 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 (err) { - await noteChannelLookupFailure({ - prompter, - label: "Slack channels", - error: err, - }); - } - } - return keys; - }, - applyAllowlist: ({ cfg, resolved }) => { - return setSlackChannelAllowlist(cfg, slackAccountId, resolved); - }, - }); - - return { cfg: next, accountId: slackAccountId }; - }, - dmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), -}; diff --git a/extensions/telegram/src/onboarding.ts b/extensions/telegram/src/onboarding.ts deleted file mode 100644 index 340319a864a..00000000000 --- a/extensions/telegram/src/onboarding.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - normalizeTelegramAllowFromInput, - parseTelegramAllowFromId, - telegramOnboardingAdapter, - telegramSetupWizard, -} from "./setup-surface.js"; diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index 4a84e48a743..f4ffe6ef809 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -35,7 +35,10 @@ export { resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { discordOnboardingAdapter } from "../../extensions/discord/src/onboarding.js"; +export { + discordSetupAdapter, + discordSetupWizard, +} from "../../extensions/discord/src/setup-surface.js"; export { DiscordConfigSchema } from "../config/zod-schema.providers-core.js"; export { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 308c63e2920..36562427e18 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -678,7 +678,10 @@ export { export { inspectDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; export type { InspectedDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; export { collectDiscordAuditChannelIds } from "../../extensions/discord/src/audit.js"; -export { discordOnboardingAdapter } from "../../extensions/discord/src/onboarding.js"; +export { + discordSetupAdapter, + discordSetupWizard, +} from "../../extensions/discord/src/setup-surface.js"; export { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget, @@ -727,7 +730,7 @@ export { extractSlackToolSend, listSlackMessageActions, } from "../../extensions/slack/src/message-actions.js"; -export { slackOnboardingAdapter } from "../../extensions/slack/src/onboarding.js"; +export { slackSetupAdapter, slackSetupWizard } from "../../extensions/slack/src/setup-surface.js"; export { looksLikeSlackTargetId, normalizeSlackMessagingTarget, @@ -743,7 +746,10 @@ export { } from "../../extensions/telegram/src/accounts.js"; export { inspectTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; export type { InspectedTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; -export { telegramOnboardingAdapter } from "../../extensions/telegram/src/onboarding.js"; +export { + telegramSetupAdapter, + telegramSetupWizard, +} from "../../extensions/telegram/src/setup-surface.js"; export { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget, diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index c05d9786d5c..779560b930b 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -39,7 +39,7 @@ export { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { slackOnboardingAdapter } from "../../extensions/slack/src/onboarding.js"; +export { slackSetupAdapter, slackSetupWizard } from "../../extensions/slack/src/setup-surface.js"; export { SlackConfigSchema } from "../config/zod-schema.providers-core.js"; export { handleSlackMessageAction } from "./slack-message-actions.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index e0d4827b879..d005a2af1f1 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -59,19 +59,23 @@ describe("plugin-sdk subpath exports", () => { it("exports Discord helpers", () => { expect(typeof discordSdk.resolveDiscordAccount).toBe("function"); expect(typeof discordSdk.inspectDiscordAccount).toBe("function"); - expect(typeof discordSdk.discordOnboardingAdapter).toBe("object"); + expect(typeof discordSdk.discordSetupWizard).toBe("object"); + expect(typeof discordSdk.discordSetupAdapter).toBe("object"); }); it("exports Slack helpers", () => { expect(typeof slackSdk.resolveSlackAccount).toBe("function"); expect(typeof slackSdk.inspectSlackAccount).toBe("function"); expect(typeof slackSdk.handleSlackMessageAction).toBe("function"); + expect(typeof slackSdk.slackSetupWizard).toBe("object"); + expect(typeof slackSdk.slackSetupAdapter).toBe("object"); }); it("exports Telegram helpers", () => { expect(typeof telegramSdk.resolveTelegramAccount).toBe("function"); expect(typeof telegramSdk.inspectTelegramAccount).toBe("function"); - expect(typeof telegramSdk.telegramOnboardingAdapter).toBe("object"); + expect(typeof telegramSdk.telegramSetupWizard).toBe("object"); + expect(typeof telegramSdk.telegramSetupAdapter).toBe("object"); }); it("exports Signal helpers", () => { diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index f9d8d0ed723..64502bf2703 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -64,7 +64,10 @@ export { resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { telegramOnboardingAdapter } from "../../extensions/telegram/src/onboarding.js"; +export { + telegramSetupAdapter, + telegramSetupWizard, +} from "../../extensions/telegram/src/setup-surface.js"; export { TelegramConfigSchema } from "../config/zod-schema.providers-core.js"; export { buildTokenChannelStatusSummary } from "./status-helpers.js"; From c3ed3ba31016b4d0458b27dd91d458845e79e34f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 16:48:05 -0700 Subject: [PATCH 048/943] docs: update setup wizard capabilities --- docs/tools/plugin.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index dd70badb37a..de162c2ab42 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1378,7 +1378,7 @@ Notes: Preferred setup split: - `plugin.setup` owns account-id normalization, validation, and config writes. -- `plugin.setupWizard` lets the host run the common wizard flow while the channel only supplies status/credential/allowlist descriptors. +- `plugin.setupWizard` lets the host run the common wizard flow while the channel only supplies status, credential, DM allowlist, and channel-access descriptors. Use `plugin.onboarding` only when the host-owned setup wizard cannot express the flow and the channel needs to fully own prompting. @@ -1393,7 +1393,9 @@ Wizard precedence: `plugin.setupWizard` is best for channels that fit the shared pattern: - one account picker driven by `plugin.config.listAccountIds` -- one primary credential prompt written via `plugin.setup.applyAccountConfig` +- optional env-shortcut prompt for bundled credential sets (for example paired bot/app tokens) +- one or more credential prompts, with each step either writing through `plugin.setup.applyAccountConfig` or a channel-owned partial patch +- optional channel/group access allowlist prompts resolved by the host - optional DM allowlist resolution (for example `@username` -> numeric id) `plugin.onboarding` hooks still return the same values as before: From a058bf918dda7bb422d042bed576bf766637920c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 16:51:04 -0700 Subject: [PATCH 049/943] feat(plugins): test bundle MCP end to end --- scripts/e2e/plugins-docker.sh | 3 + src/agents/cli-runner.bundle-mcp.e2e.test.ts | 205 ++++++++++++ src/agents/cli-runner.ts | 126 ++++---- src/agents/cli-runner/bundle-mcp.test.ts | 93 ++++++ src/agents/cli-runner/bundle-mcp.ts | 143 +++++++++ .../reply/dispatch-from-config.test.ts | 5 +- src/plugins/bundle-mcp.test.ts | 148 +++++++++ src/plugins/bundle-mcp.ts | 300 ++++++++++++++++++ src/plugins/conversation-binding.test.ts | 4 +- 9 files changed, 968 insertions(+), 59 deletions(-) create mode 100644 src/agents/cli-runner.bundle-mcp.e2e.test.ts create mode 100644 src/agents/cli-runner/bundle-mcp.test.ts create mode 100644 src/agents/cli-runner/bundle-mcp.ts create mode 100644 src/plugins/bundle-mcp.test.ts create mode 100644 src/plugins/bundle-mcp.ts diff --git a/scripts/e2e/plugins-docker.sh b/scripts/e2e/plugins-docker.sh index f4797b931e0..854a92606ed 100755 --- a/scripts/e2e/plugins-docker.sh +++ b/scripts/e2e/plugins-docker.sh @@ -219,6 +219,9 @@ if (!Array.isArray(plugin.gatewayMethods) || !plugin.gatewayMethods.includes("de } console.log("ok"); NODE + + echo "Running bundle MCP CLI-agent e2e..." + pnpm exec vitest run --config vitest.e2e.config.ts src/agents/cli-runner.bundle-mcp.e2e.test.ts ' echo "OK" diff --git a/src/agents/cli-runner.bundle-mcp.e2e.test.ts b/src/agents/cli-runner.bundle-mcp.e2e.test.ts new file mode 100644 index 00000000000..7210c563467 --- /dev/null +++ b/src/agents/cli-runner.bundle-mcp.e2e.test.ts @@ -0,0 +1,205 @@ +import fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { captureEnv } from "../test-utils/env.js"; +import { runCliAgent } from "./cli-runner.js"; + +const E2E_TIMEOUT_MS = 20_000; +const require = createRequire(import.meta.url); +const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js"); +const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js"); +const SDK_CLIENT_INDEX_PATH = require.resolve("@modelcontextprotocol/sdk/client/index.js"); +const SDK_CLIENT_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/client/stdio.js"); + +async function writeExecutable(filePath: string, content: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 }); +} + +async function writeBundleProbeMcpServer(filePath: string): Promise { + await writeExecutable( + filePath, + `#!/usr/bin/env node +import { McpServer } from ${JSON.stringify(SDK_SERVER_MCP_PATH)}; +import { StdioServerTransport } from ${JSON.stringify(SDK_SERVER_STDIO_PATH)}; + +const server = new McpServer({ name: "bundle-probe", version: "1.0.0" }); +server.tool("bundle_probe", "Bundle MCP probe", async () => { + return { + content: [{ type: "text", text: process.env.BUNDLE_PROBE_TEXT ?? "missing-probe-text" }], + }; +}); + +await server.connect(new StdioServerTransport()); +`, + ); +} + +async function writeFakeClaudeCli(filePath: string): Promise { + await writeExecutable( + filePath, + `#!/usr/bin/env node +import fs from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { Client } from ${JSON.stringify(SDK_CLIENT_INDEX_PATH)}; +import { StdioClientTransport } from ${JSON.stringify(SDK_CLIENT_STDIO_PATH)}; + +function readArg(name) { + const args = process.argv.slice(2); + for (let i = 0; i < args.length; i += 1) { + const arg = args[i] ?? ""; + if (arg === name) { + return args[i + 1]; + } + if (arg.startsWith(name + "=")) { + return arg.slice(name.length + 1); + } + } + return undefined; +} + +const mcpConfigPath = readArg("--mcp-config"); +if (!mcpConfigPath) { + throw new Error("missing --mcp-config"); +} + +const raw = JSON.parse(await fs.readFile(mcpConfigPath, "utf-8")); +const servers = raw?.mcpServers ?? raw?.servers ?? {}; +const server = servers.bundleProbe ?? Object.values(servers)[0]; +if (!server || typeof server !== "object") { + throw new Error("missing bundleProbe MCP server"); +} + +const transport = new StdioClientTransport({ + command: server.command, + args: Array.isArray(server.args) ? server.args : [], + env: server.env && typeof server.env === "object" ? server.env : undefined, + cwd: + typeof server.cwd === "string" + ? server.cwd + : typeof server.workingDirectory === "string" + ? server.workingDirectory + : undefined, +}); +const client = new Client({ name: "fake-claude", version: "1.0.0" }); +await client.connect(transport); +const tools = await client.listTools(); +if (!tools.tools.some((tool) => tool.name === "bundle_probe")) { + throw new Error("bundle_probe tool not exposed"); +} +const result = await client.callTool({ name: "bundle_probe", arguments: {} }); +await transport.close(); + +const text = Array.isArray(result.content) + ? result.content + .filter((entry) => entry?.type === "text" && typeof entry.text === "string") + .map((entry) => entry.text) + .join("\\n") + : ""; + +process.stdout.write( + JSON.stringify({ + session_id: readArg("--session-id") ?? randomUUID(), + message: "BUNDLE MCP OK " + text, + }) + "\\n", +); +`, + ); +} + +async function writeClaudeBundle(params: { + pluginRoot: string; + serverScriptPath: string; +}): Promise { + await fs.mkdir(path.join(params.pluginRoot, ".claude-plugin"), { recursive: true }); + await fs.writeFile( + path.join(params.pluginRoot, ".claude-plugin", "plugin.json"), + `${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile( + path.join(params.pluginRoot, ".mcp.json"), + `${JSON.stringify( + { + mcpServers: { + bundleProbe: { + command: "node", + args: [path.relative(params.pluginRoot, params.serverScriptPath)], + env: { + BUNDLE_PROBE_TEXT: "FROM-BUNDLE", + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); +} + +describe("runCliAgent bundle MCP e2e", () => { + it( + "routes enabled bundle MCP config into the claude-cli backend and executes the tool", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const envSnapshot = captureEnv(["HOME"]); + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-bundle-mcp-")); + process.env.HOME = tempHome; + + const workspaceDir = path.join(tempHome, "workspace"); + const sessionFile = path.join(tempHome, "session.jsonl"); + const binDir = path.join(tempHome, "bin"); + const serverScriptPath = path.join(tempHome, "mcp", "bundle-probe.mjs"); + const fakeClaudePath = path.join(binDir, "fake-claude.mjs"); + const pluginRoot = path.join(tempHome, ".openclaw", "extensions", "bundle-probe"); + await fs.mkdir(workspaceDir, { recursive: true }); + await writeBundleProbeMcpServer(serverScriptPath); + await writeFakeClaudeCli(fakeClaudePath); + await writeClaudeBundle({ pluginRoot, serverScriptPath }); + + const config: OpenClawConfig = { + agents: { + defaults: { + workspace: workspaceDir, + cliBackends: { + "claude-cli": { + command: "node", + args: [fakeClaudePath], + clearEnv: [], + }, + }, + }, + }, + plugins: { + entries: { + "bundle-probe": { enabled: true }, + }, + }, + }; + + try { + const result = await runCliAgent({ + sessionId: "session:test", + sessionFile, + workspaceDir, + config, + prompt: "Use your configured MCP tools and report the bundle probe text.", + provider: "claude-cli", + model: "test-bundle", + timeoutMs: 10_000, + runId: "bundle-mcp-e2e", + }); + + expect(result.payloads?.[0]?.text).toContain("BUNDLE MCP OK FROM-BUNDLE"); + expect(result.meta.agentMeta?.sessionId.length ?? 0).toBeGreaterThan(0); + } finally { + await fs.rm(tempHome, { recursive: true, force: true }); + envSnapshot.restore(); + } + }, + ); +}); diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 3dfe728ce31..f9b0f5542c5 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -18,6 +18,7 @@ import { } from "./bootstrap-budget.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js"; import { resolveCliBackendConfig } from "./cli-backends.js"; +import { prepareCliBundleMcpConfig } from "./cli-runner/bundle-mcp.js"; import { appendImagePathsToPrompt, buildCliSupervisorScopeKey, @@ -92,7 +93,14 @@ export async function runCliAgent(params: { if (!backendResolved) { throw new Error(`Unknown CLI backend: ${params.provider}`); } - const backend = backendResolved.config; + const preparedBackend = await prepareCliBundleMcpConfig({ + backendId: backendResolved.id, + backend: backendResolved.config, + workspaceDir, + config: params.config, + warn: (message) => log.warn(message), + }); + const backend = preparedBackend.backend; const modelId = (params.model ?? "default").trim() || "default"; const normalizedModel = normalizeCliModel(modelId, backend); const modelDisplay = `${params.provider}/${modelId}`; @@ -406,68 +414,72 @@ export async function runCliAgent(params: { // Try with the provided CLI session ID first try { - const output = await executeCliWithSession(params.cliSessionId); - const text = output.text?.trim(); - const payloads = text ? [{ text }] : undefined; + try { + const output = await executeCliWithSession(params.cliSessionId); + const text = output.text?.trim(); + const payloads = text ? [{ text }] : undefined; - return { - payloads, - meta: { - durationMs: Date.now() - started, - systemPromptReport, - agentMeta: { - sessionId: output.sessionId ?? params.cliSessionId ?? params.sessionId ?? "", + return { + payloads, + meta: { + durationMs: Date.now() - started, + systemPromptReport, + agentMeta: { + sessionId: output.sessionId ?? params.cliSessionId ?? params.sessionId ?? "", + provider: params.provider, + model: modelId, + usage: output.usage, + }, + }, + }; + } catch (err) { + if (err instanceof FailoverError) { + // Check if this is a session expired error and we have a session to clear + if (err.reason === "session_expired" && params.cliSessionId && params.sessionKey) { + log.warn( + `CLI session expired, clearing session ID and retrying: provider=${params.provider} session=${redactRunIdentifier(params.cliSessionId)}`, + ); + + // Clear the expired session ID from the session entry + // This requires access to the session store, which we don't have here + // We'll need to modify the caller to handle this case + + // For now, retry without the session ID to create a new session + const output = await executeCliWithSession(undefined); + const text = output.text?.trim(); + const payloads = text ? [{ text }] : undefined; + + return { + payloads, + meta: { + durationMs: Date.now() - started, + systemPromptReport, + agentMeta: { + sessionId: output.sessionId ?? params.sessionId ?? "", + provider: params.provider, + model: modelId, + usage: output.usage, + }, + }, + }; + } + throw err; + } + const message = err instanceof Error ? err.message : String(err); + if (isFailoverErrorMessage(message)) { + const reason = classifyFailoverReason(message) ?? "unknown"; + const status = resolveFailoverStatus(reason); + throw new FailoverError(message, { + reason, provider: params.provider, model: modelId, - usage: output.usage, - }, - }, - }; - } catch (err) { - if (err instanceof FailoverError) { - // Check if this is a session expired error and we have a session to clear - if (err.reason === "session_expired" && params.cliSessionId && params.sessionKey) { - log.warn( - `CLI session expired, clearing session ID and retrying: provider=${params.provider} session=${redactRunIdentifier(params.cliSessionId)}`, - ); - - // Clear the expired session ID from the session entry - // This requires access to the session store, which we don't have here - // We'll need to modify the caller to handle this case - - // For now, retry without the session ID to create a new session - const output = await executeCliWithSession(undefined); - const text = output.text?.trim(); - const payloads = text ? [{ text }] : undefined; - - return { - payloads, - meta: { - durationMs: Date.now() - started, - systemPromptReport, - agentMeta: { - sessionId: output.sessionId ?? params.sessionId ?? "", - provider: params.provider, - model: modelId, - usage: output.usage, - }, - }, - }; + status, + }); } throw err; } - const message = err instanceof Error ? err.message : String(err); - if (isFailoverErrorMessage(message)) { - const reason = classifyFailoverReason(message) ?? "unknown"; - const status = resolveFailoverStatus(reason); - throw new FailoverError(message, { - reason, - provider: params.provider, - model: modelId, - status, - }); - } - throw err; + } finally { + await preparedBackend.cleanup?.(); } } diff --git a/src/agents/cli-runner/bundle-mcp.test.ts b/src/agents/cli-runner/bundle-mcp.test.ts new file mode 100644 index 00000000000..ec345f960a2 --- /dev/null +++ b/src/agents/cli-runner/bundle-mcp.test.ts @@ -0,0 +1,93 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { clearPluginManifestRegistryCache } from "../../plugins/manifest-registry.js"; +import { captureEnv } from "../../test-utils/env.js"; +import { prepareCliBundleMcpConfig } from "./bundle-mcp.js"; + +const tempDirs: string[] = []; + +async function createTempDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + clearPluginManifestRegistryCache(); + await Promise.all( + tempDirs.splice(0, tempDirs.length).map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); +}); + +describe("prepareCliBundleMcpConfig", () => { + it("injects a merged --mcp-config overlay for claude-cli", async () => { + const env = captureEnv(["HOME"]); + try { + const homeDir = await createTempDir("openclaw-cli-bundle-mcp-home-"); + const workspaceDir = await createTempDir("openclaw-cli-bundle-mcp-workspace-"); + process.env.HOME = homeDir; + + const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "bundle-probe"); + const serverPath = path.join(pluginRoot, "servers", "probe.mjs"); + await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); + await fs.mkdir(path.dirname(serverPath), { recursive: true }); + await fs.writeFile(serverPath, "export {};\n", "utf-8"); + await fs.writeFile( + path.join(pluginRoot, ".claude-plugin", "plugin.json"), + `${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile( + path.join(pluginRoot, ".mcp.json"), + `${JSON.stringify( + { + mcpServers: { + bundleProbe: { + command: "node", + args: ["./servers/probe.mjs"], + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + + const config: OpenClawConfig = { + plugins: { + entries: { + "bundle-probe": { enabled: true }, + }, + }, + }; + + const prepared = await prepareCliBundleMcpConfig({ + backendId: "claude-cli", + backend: { + command: "node", + args: ["./fake-claude.mjs"], + }, + workspaceDir, + config, + }); + + const configFlagIndex = prepared.backend.args?.indexOf("--mcp-config") ?? -1; + expect(configFlagIndex).toBeGreaterThanOrEqual(0); + expect(prepared.backend.args).toContain("--strict-mcp-config"); + const generatedConfigPath = prepared.backend.args?.[configFlagIndex + 1]; + expect(typeof generatedConfigPath).toBe("string"); + const raw = JSON.parse(await fs.readFile(generatedConfigPath as string, "utf-8")) as { + mcpServers?: Record; + }; + expect(raw.mcpServers?.bundleProbe?.args).toEqual([await fs.realpath(serverPath)]); + + await prepared.cleanup?.(); + } finally { + env.restore(); + } + }); +}); diff --git a/src/agents/cli-runner/bundle-mcp.ts b/src/agents/cli-runner/bundle-mcp.ts new file mode 100644 index 00000000000..60e6149519c --- /dev/null +++ b/src/agents/cli-runner/bundle-mcp.ts @@ -0,0 +1,143 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { OpenClawConfig } from "../../config/config.js"; +import { applyMergePatch } from "../../config/merge-patch.js"; +import type { CliBackendConfig } from "../../config/types.js"; +import { + loadEnabledBundleMcpConfig, + type BundleMcpConfig, + type BundleMcpServerConfig, +} from "../../plugins/bundle-mcp.js"; +import { isRecord } from "../../utils.js"; + +type PreparedCliBundleMcpConfig = { + backend: CliBackendConfig; + cleanup?: () => Promise; +}; + +function extractServerMap(raw: unknown): Record { + if (!isRecord(raw)) { + return {}; + } + const nested = isRecord(raw.mcpServers) + ? raw.mcpServers + : isRecord(raw.servers) + ? raw.servers + : raw; + if (!isRecord(nested)) { + return {}; + } + const result: Record = {}; + for (const [serverName, serverRaw] of Object.entries(nested)) { + if (!isRecord(serverRaw)) { + continue; + } + result[serverName] = { ...serverRaw }; + } + return result; +} + +async function readExternalMcpConfig(configPath: string): Promise { + try { + const raw = JSON.parse(await fs.readFile(configPath, "utf-8")) as unknown; + return { mcpServers: extractServerMap(raw) }; + } catch { + return { mcpServers: {} }; + } +} + +function findMcpConfigPath(args?: string[]): string | undefined { + if (!args?.length) { + return undefined; + } + for (let i = 0; i < args.length; i += 1) { + const arg = args[i] ?? ""; + if (arg === "--mcp-config") { + const next = args[i + 1]; + return typeof next === "string" && next.trim() ? next.trim() : undefined; + } + if (arg.startsWith("--mcp-config=")) { + const inline = arg.slice("--mcp-config=".length).trim(); + return inline || undefined; + } + } + return undefined; +} + +function injectMcpConfigArgs(args: string[] | undefined, mcpConfigPath: string): string[] { + const next: string[] = []; + for (let i = 0; i < (args?.length ?? 0); i += 1) { + const arg = args?.[i] ?? ""; + if (arg === "--strict-mcp-config") { + continue; + } + if (arg === "--mcp-config") { + i += 1; + continue; + } + if (arg.startsWith("--mcp-config=")) { + continue; + } + next.push(arg); + } + next.push("--strict-mcp-config", "--mcp-config", mcpConfigPath); + return next; +} + +export async function prepareCliBundleMcpConfig(params: { + backendId: string; + backend: CliBackendConfig; + workspaceDir: string; + config?: OpenClawConfig; + warn?: (message: string) => void; +}): Promise { + if (params.backendId !== "claude-cli") { + return { backend: params.backend }; + } + + const existingMcpConfigPath = + findMcpConfigPath(params.backend.resumeArgs) ?? findMcpConfigPath(params.backend.args); + let mergedConfig: BundleMcpConfig = { mcpServers: {} }; + + if (existingMcpConfigPath) { + const resolvedExistingPath = path.isAbsolute(existingMcpConfigPath) + ? existingMcpConfigPath + : path.resolve(params.workspaceDir, existingMcpConfigPath); + mergedConfig = applyMergePatch( + mergedConfig, + await readExternalMcpConfig(resolvedExistingPath), + ) as BundleMcpConfig; + } + + const bundleConfig = loadEnabledBundleMcpConfig({ + workspaceDir: params.workspaceDir, + cfg: params.config, + }); + for (const diagnostic of bundleConfig.diagnostics) { + params.warn?.(`bundle MCP skipped for ${diagnostic.pluginId}: ${diagnostic.message}`); + } + mergedConfig = applyMergePatch(mergedConfig, bundleConfig.config) as BundleMcpConfig; + + if (Object.keys(mergedConfig.mcpServers).length === 0) { + return { backend: params.backend }; + } + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-mcp-")); + const mcpConfigPath = path.join(tempDir, "mcp.json"); + await fs.writeFile(mcpConfigPath, `${JSON.stringify(mergedConfig, null, 2)}\n`, "utf-8"); + + return { + backend: { + ...params.backend, + args: injectMcpConfigArgs(params.backend.args, mcpConfigPath), + resumeArgs: injectMcpConfigArgs( + params.backend.resumeArgs ?? params.backend.args ?? [], + mcpConfigPath, + ), + }, + cleanup: async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }, + }; +} diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index ed41db9664e..38e3615dd9f 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { AcpRuntimeError } from "../../acp/runtime/errors.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js"; +import type { PluginTargetedInboundClaimOutcome } from "../../plugins/hooks.js"; import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js"; import type { MsgContext } from "../templating.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; @@ -33,7 +34,9 @@ const hookMocks = vi.hoisted(() => ({ hasHooks: vi.fn(() => false), runInboundClaim: vi.fn(async () => undefined), runInboundClaimForPlugin: vi.fn(async () => undefined), - runInboundClaimForPluginOutcome: vi.fn(async () => ({ status: "no_handler" as const })), + runInboundClaimForPluginOutcome: vi.fn<() => Promise>( + async () => ({ status: "no_handler" as const }), + ), runMessageReceived: vi.fn(async () => {}), }, })); diff --git a/src/plugins/bundle-mcp.test.ts b/src/plugins/bundle-mcp.test.ts new file mode 100644 index 00000000000..122c7a83c5c --- /dev/null +++ b/src/plugins/bundle-mcp.test.ts @@ -0,0 +1,148 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { captureEnv } from "../test-utils/env.js"; +import { loadEnabledBundleMcpConfig } from "./bundle-mcp.js"; +import { clearPluginManifestRegistryCache } from "./manifest-registry.js"; + +const tempDirs: string[] = []; + +async function createTempDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + clearPluginManifestRegistryCache(); + await Promise.all( + tempDirs.splice(0, tempDirs.length).map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); +}); + +describe("loadEnabledBundleMcpConfig", () => { + it("loads enabled Claude bundle MCP config and absolutizes relative args", async () => { + const env = captureEnv(["HOME"]); + try { + const homeDir = await createTempDir("openclaw-bundle-mcp-home-"); + const workspaceDir = await createTempDir("openclaw-bundle-mcp-workspace-"); + process.env.HOME = homeDir; + + const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "bundle-probe"); + const serverPath = path.join(pluginRoot, "servers", "probe.mjs"); + await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); + await fs.mkdir(path.dirname(serverPath), { recursive: true }); + await fs.writeFile(serverPath, "export {};\n", "utf-8"); + await fs.writeFile( + path.join(pluginRoot, ".claude-plugin", "plugin.json"), + `${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`, + "utf-8", + ); + await fs.writeFile( + path.join(pluginRoot, ".mcp.json"), + `${JSON.stringify( + { + mcpServers: { + bundleProbe: { + command: "node", + args: ["./servers/probe.mjs"], + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + + const config: OpenClawConfig = { + plugins: { + entries: { + "bundle-probe": { enabled: true }, + }, + }, + }; + + const loaded = loadEnabledBundleMcpConfig({ + workspaceDir, + cfg: config, + }); + const resolvedServerPath = await fs.realpath(serverPath); + + expect(loaded.diagnostics).toEqual([]); + expect(loaded.config.mcpServers.bundleProbe?.command).toBe("node"); + expect(loaded.config.mcpServers.bundleProbe?.args).toEqual([resolvedServerPath]); + } finally { + env.restore(); + } + }); + + it("merges inline bundle MCP servers and skips disabled bundles", async () => { + const env = captureEnv(["HOME"]); + try { + const homeDir = await createTempDir("openclaw-bundle-inline-home-"); + const workspaceDir = await createTempDir("openclaw-bundle-inline-workspace-"); + process.env.HOME = homeDir; + + const enabledRoot = path.join(homeDir, ".openclaw", "extensions", "inline-enabled"); + const disabledRoot = path.join(homeDir, ".openclaw", "extensions", "inline-disabled"); + await fs.mkdir(path.join(enabledRoot, ".claude-plugin"), { recursive: true }); + await fs.mkdir(path.join(disabledRoot, ".claude-plugin"), { recursive: true }); + await fs.writeFile( + path.join(enabledRoot, ".claude-plugin", "plugin.json"), + `${JSON.stringify( + { + name: "inline-enabled", + mcpServers: { + enabledProbe: { + command: "node", + args: ["./enabled.mjs"], + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + await fs.writeFile( + path.join(disabledRoot, ".claude-plugin", "plugin.json"), + `${JSON.stringify( + { + name: "inline-disabled", + mcpServers: { + disabledProbe: { + command: "node", + args: ["./disabled.mjs"], + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + + const config: OpenClawConfig = { + plugins: { + entries: { + "inline-enabled": { enabled: true }, + "inline-disabled": { enabled: false }, + }, + }, + }; + + const loaded = loadEnabledBundleMcpConfig({ + workspaceDir, + cfg: config, + }); + + expect(loaded.config.mcpServers.enabledProbe).toBeDefined(); + expect(loaded.config.mcpServers.disabledProbe).toBeUndefined(); + } finally { + env.restore(); + } + }); +}); diff --git a/src/plugins/bundle-mcp.ts b/src/plugins/bundle-mcp.ts new file mode 100644 index 00000000000..6ce186384c7 --- /dev/null +++ b/src/plugins/bundle-mcp.ts @@ -0,0 +1,300 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { OpenClawConfig } from "../config/config.js"; +import { applyMergePatch } from "../config/merge-patch.js"; +import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; +import { isRecord } from "../utils.js"; +import { + CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, + CODEX_BUNDLE_MANIFEST_RELATIVE_PATH, + CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH, +} from "./bundle-manifest.js"; +import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; +import { loadPluginManifestRegistry } from "./manifest-registry.js"; +import type { PluginBundleFormat } from "./types.js"; + +export type BundleMcpServerConfig = Record; + +export type BundleMcpConfig = { + mcpServers: Record; +}; + +export type BundleMcpDiagnostic = { + pluginId: string; + message: string; +}; + +export type EnabledBundleMcpConfigResult = { + config: BundleMcpConfig; + diagnostics: BundleMcpDiagnostic[]; +}; + +const MANIFEST_PATH_BY_FORMAT: Record = { + claude: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, + codex: CODEX_BUNDLE_MANIFEST_RELATIVE_PATH, + cursor: CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH, +}; + +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; + allowMissing?: boolean; +}): { ok: true; raw: Record } | { ok: false; error: string } { + const absolutePath = path.join(params.rootDir, params.relativePath); + const opened = openBoundaryFileSync({ + absolutePath, + rootPath: params.rootDir, + boundaryLabel: "plugin root", + rejectHardlinks: true, + }); + if (!opened.ok) { + if (opened.reason === "path" && params.allowMissing) { + return { ok: true, raw: {} }; + } + return { ok: false, error: `unable to read ${params.relativePath}: ${opened.reason}` }; + } + try { + const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown; + if (!isRecord(raw)) { + return { ok: false, error: `${params.relativePath} must contain a JSON object` }; + } + return { ok: true, raw }; + } catch (error) { + return { ok: false, error: `failed to parse ${params.relativePath}: ${String(error)}` }; + } finally { + fs.closeSync(opened.fd); + } +} + +function resolveBundleMcpConfigPaths(params: { + raw: Record; + rootDir: string; + bundleFormat: PluginBundleFormat; +}): string[] { + const declared = normalizePathList(params.raw.mcpServers); + const defaults = fs.existsSync(path.join(params.rootDir, ".mcp.json")) ? [".mcp.json"] : []; + if (params.bundleFormat === "claude") { + return mergeUniquePathLists(defaults, declared); + } + return mergeUniquePathLists(defaults, declared); +} + +function extractMcpServerMap(raw: unknown): Record { + if (!isRecord(raw)) { + return {}; + } + const nested = isRecord(raw.mcpServers) + ? raw.mcpServers + : isRecord(raw.servers) + ? raw.servers + : raw; + if (!isRecord(nested)) { + return {}; + } + const result: Record = {}; + for (const [serverName, serverRaw] of Object.entries(nested)) { + if (!isRecord(serverRaw)) { + continue; + } + result[serverName] = { ...serverRaw }; + } + return result; +} + +function isExplicitRelativePath(value: string): boolean { + return value === "." || value === ".." || value.startsWith("./") || value.startsWith("../"); +} + +function absolutizeBundleMcpServer(params: { + baseDir: string; + server: BundleMcpServerConfig; +}): BundleMcpServerConfig { + const next: BundleMcpServerConfig = { ...params.server }; + + const command = next.command; + if (typeof command === "string" && isExplicitRelativePath(command)) { + next.command = path.resolve(params.baseDir, command); + } + + const cwd = next.cwd; + if (typeof cwd === "string" && !path.isAbsolute(cwd)) { + next.cwd = path.resolve(params.baseDir, cwd); + } + + const workingDirectory = next.workingDirectory; + if (typeof workingDirectory === "string" && !path.isAbsolute(workingDirectory)) { + next.workingDirectory = path.resolve(params.baseDir, workingDirectory); + } + + if (Array.isArray(next.args)) { + next.args = next.args.map((entry) => { + if (typeof entry !== "string" || !isExplicitRelativePath(entry)) { + return entry; + } + return path.resolve(params.baseDir, entry); + }); + } + + return next; +} + +function loadBundleFileBackedMcpConfig(params: { + rootDir: string; + relativePath: string; +}): BundleMcpConfig { + const absolutePath = path.resolve(params.rootDir, params.relativePath); + const opened = openBoundaryFileSync({ + absolutePath, + rootPath: params.rootDir, + boundaryLabel: "plugin root", + rejectHardlinks: true, + }); + if (!opened.ok) { + return { mcpServers: {} }; + } + try { + const stat = fs.fstatSync(opened.fd); + if (!stat.isFile()) { + return { mcpServers: {} }; + } + const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown; + const servers = extractMcpServerMap(raw); + const baseDir = path.dirname(absolutePath); + return { + mcpServers: Object.fromEntries( + Object.entries(servers).map(([serverName, server]) => [ + serverName, + absolutizeBundleMcpServer({ baseDir, server }), + ]), + ), + }; + } finally { + fs.closeSync(opened.fd); + } +} + +function loadBundleInlineMcpConfig(params: { + raw: Record; + baseDir: string; +}): BundleMcpConfig { + if (!isRecord(params.raw.mcpServers)) { + return { mcpServers: {} }; + } + const servers = extractMcpServerMap(params.raw.mcpServers); + return { + mcpServers: Object.fromEntries( + Object.entries(servers).map(([serverName, server]) => [ + serverName, + absolutizeBundleMcpServer({ baseDir: params.baseDir, server }), + ]), + ), + }; +} + +function loadBundleMcpConfig(params: { + pluginId: string; + rootDir: string; + bundleFormat: PluginBundleFormat; +}): { config: BundleMcpConfig; diagnostics: string[] } { + const manifestRelativePath = MANIFEST_PATH_BY_FORMAT[params.bundleFormat]; + const manifestLoaded = readPluginJsonObject({ + rootDir: params.rootDir, + relativePath: manifestRelativePath, + allowMissing: params.bundleFormat === "claude", + }); + if (!manifestLoaded.ok) { + return { config: { mcpServers: {} }, diagnostics: [manifestLoaded.error] }; + } + + let merged: BundleMcpConfig = { mcpServers: {} }; + const filePaths = resolveBundleMcpConfigPaths({ + raw: manifestLoaded.raw, + rootDir: params.rootDir, + bundleFormat: params.bundleFormat, + }); + for (const relativePath of filePaths) { + merged = applyMergePatch( + merged, + loadBundleFileBackedMcpConfig({ + rootDir: params.rootDir, + relativePath, + }), + ) as BundleMcpConfig; + } + + merged = applyMergePatch( + merged, + loadBundleInlineMcpConfig({ + raw: manifestLoaded.raw, + baseDir: path.dirname(path.join(params.rootDir, manifestRelativePath)), + }), + ) as BundleMcpConfig; + + return { config: merged, diagnostics: [] }; +} + +export function loadEnabledBundleMcpConfig(params: { + workspaceDir: string; + cfg?: OpenClawConfig; +}): EnabledBundleMcpConfigResult { + const registry = loadPluginManifestRegistry({ + workspaceDir: params.workspaceDir, + config: params.cfg, + }); + const normalizedPlugins = normalizePluginsConfig(params.cfg?.plugins); + const diagnostics: BundleMcpDiagnostic[] = []; + let merged: BundleMcpConfig = { mcpServers: {} }; + + for (const record of registry.plugins) { + if (record.format !== "bundle" || !record.bundleFormat) { + continue; + } + const enableState = resolveEffectiveEnableState({ + id: record.id, + origin: record.origin, + config: normalizedPlugins, + rootConfig: params.cfg, + }); + if (!enableState.enabled) { + continue; + } + + const loaded = loadBundleMcpConfig({ + pluginId: record.id, + rootDir: record.rootDir, + bundleFormat: record.bundleFormat, + }); + merged = applyMergePatch(merged, loaded.config) as BundleMcpConfig; + for (const message of loaded.diagnostics) { + diagnostics.push({ pluginId: record.id, message }); + } + } + + return { config: merged, diagnostics }; +} diff --git a/src/plugins/conversation-binding.test.ts b/src/plugins/conversation-binding.test.ts index 821fd9e3b48..0a673572d59 100644 --- a/src/plugins/conversation-binding.test.ts +++ b/src/plugins/conversation-binding.test.ts @@ -326,8 +326,10 @@ describe("plugin conversation binding approvals", () => { } expect(approved.binding.detachHint).toBe("/codex_detach"); - } else { + } else if (request.status === "bound") { expect(request.binding.detachHint).toBe("/codex_detach"); + } else { + throw new Error(`expected pending or bound request, got ${request.status}`); } const currentBinding = await getCurrentPluginConversationBinding({ From f4cc93dc7da7359c35130bbbb244d3fac695740f Mon Sep 17 00:00:00 2001 From: Mason Date: Mon, 16 Mar 2026 07:52:08 +0800 Subject: [PATCH 050/943] fix(onboarding): use scoped plugin snapshots to prevent OOM on low-memory hosts (#46763) * fix(onboarding): use scoped plugin snapshots to prevent OOM on low-memory hosts Onboarding and channel-add flows previously loaded the full plugin registry, which caused OOM crashes on memory-constrained hosts. This patch introduces scoped, non-activating plugin registry snapshots that load only the selected channel plugin without replacing the running gateway's global state. Key changes: - Add onlyPluginIds and activate options to loadOpenClawPlugins for scoped loads - Add suppressGlobalCommands to plugin registry to avoid leaking commands - Replace full registry reloads in onboarding with per-channel scoped snapshots - Validate command definitions in snapshot loads without writing global registry - Preload configured external plugins via scoped discovery during onboarding Co-Authored-By: Claude Opus 4.6 * fix(test): add return type annotation to hoisted mock to resolve TS2322 * fix(plugins): enforce cache:false invariant for non-activating snapshot loads * Channels: preserve lazy scoped snapshot import after rebase * Onboarding: scope channel snapshots by plugin id * Catalog: trust manifest ids for channel plugin mapping * Onboarding: preserve scoped setup channel loading * Onboarding: restore built-in adapter fallback --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Vincent Koc --- src/channels/plugins/catalog.ts | 31 +- src/channels/plugins/onboarding-types.ts | 3 +- src/channels/plugins/plugins-core.test.ts | 44 +++ src/commands/channels.add.test.ts | 184 +++++++++++- src/commands/channels/add-mutators.ts | 8 +- src/commands/channels/add.ts | 44 ++- src/commands/onboard-channels.e2e.test.ts | 270 ++++++++++++++++++ src/commands/onboard-channels.ts | 149 +++++++--- .../onboarding/plugin-install.test.ts | 135 +++++++++ src/commands/onboarding/plugin-install.ts | 62 +++- src/commands/onboarding/registry.ts | 19 +- src/plugins/commands.ts | 44 +-- src/plugins/loader.test.ts | 116 +++++++- src/plugins/loader.ts | 70 ++++- src/plugins/registry.ts | 47 ++- 15 files changed, 1127 insertions(+), 99 deletions(-) diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index a853dcdf805..8f582bb8c8a 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { MANIFEST_KEY } from "../../compat/legacy-names.js"; import { discoverOpenClawPlugins } from "../../plugins/discovery.js"; +import { loadPluginManifest } from "../../plugins/manifest.js"; import type { OpenClawPackageManifest } from "../../plugins/manifest.js"; import type { PluginOrigin } from "../../plugins/types.js"; import { isRecord, resolveConfigDir, resolveUserPath } from "../../utils.js"; @@ -25,6 +26,7 @@ export type ChannelUiCatalog = { export type ChannelPluginCatalogEntry = { id: string; + pluginId?: string; meta: ChannelMeta; install: { npmSpec: string; @@ -196,9 +198,26 @@ function resolveInstallInfo(params: { }; } +function resolveCatalogPluginId(params: { + packageDir?: string; + rootDir?: string; + origin?: PluginOrigin; +}): string | undefined { + const manifestDir = params.packageDir ?? params.rootDir; + if (manifestDir) { + const manifest = loadPluginManifest(manifestDir, params.origin !== "bundled"); + if (manifest.ok) { + return manifest.manifest.id; + } + } + return undefined; +} + function buildCatalogEntry(candidate: { packageName?: string; packageDir?: string; + rootDir?: string; + origin?: PluginOrigin; workspaceDir?: string; packageManifest?: OpenClawPackageManifest; }): ChannelPluginCatalogEntry | null { @@ -223,7 +242,17 @@ function buildCatalogEntry(candidate: { if (!install) { return null; } - return { id, meta, install }; + const pluginId = resolveCatalogPluginId({ + packageDir: candidate.packageDir, + rootDir: candidate.rootDir, + origin: candidate.origin, + }); + return { + id, + ...(pluginId ? { pluginId } : {}), + meta, + install, + }; } function buildExternalCatalogEntry(entry: ExternalCatalogEntry): ChannelPluginCatalogEntry | null { diff --git a/src/channels/plugins/onboarding-types.ts b/src/channels/plugins/onboarding-types.ts index 75d1b3a62c9..f560b27b172 100644 --- a/src/channels/plugins/onboarding-types.ts +++ b/src/channels/plugins/onboarding-types.ts @@ -2,7 +2,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { DmPolicy } from "../../config/types.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; -import type { ChannelId } from "./types.js"; +import type { ChannelId, ChannelPlugin } from "./types.js"; export type SetupChannelsOptions = { allowDisable?: boolean; @@ -10,6 +10,7 @@ export type SetupChannelsOptions = { onSelection?: (selection: ChannelId[]) => void; accountIds?: Partial>; onAccountId?: (channel: ChannelId, accountId: string) => void; + onResolvedPlugin?: (channel: ChannelId, plugin: ChannelPlugin) => void; promptAccountIds?: boolean; whatsappAccountId?: string; promptWhatsAppAccountId?: boolean; diff --git a/src/channels/plugins/plugins-core.test.ts b/src/channels/plugins/plugins-core.test.ts index 8297a6b7519..2c8a7473dd6 100644 --- a/src/channels/plugins/plugins-core.test.ts +++ b/src/channels/plugins/plugins-core.test.ts @@ -154,6 +154,50 @@ describe("channel plugin catalog", () => { expect(ids).toContain("demo-channel"); }); + it("preserves plugin ids when they differ from channel ids", () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-catalog-state-")); + const pluginDir = path.join(stateDir, "extensions", "demo-channel-plugin"); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "@vendor/demo-channel-plugin", + openclaw: { + extensions: ["./index.js"], + channel: { + id: "demo-channel", + label: "Demo Channel", + selectionLabel: "Demo Channel", + docsPath: "/channels/demo-channel", + blurb: "Demo channel", + }, + install: { + npmSpec: "@vendor/demo-channel-plugin", + }, + }, + }), + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "@vendor/demo-runtime", + configSchema: {}, + }), + ); + fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {}", "utf-8"); + + const entry = listChannelPluginCatalogEntries({ + env: { + ...process.env, + OPENCLAW_STATE_DIR: stateDir, + CLAWDBOT_STATE_DIR: undefined, + OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", + }, + }).find((item) => item.id === "demo-channel"); + + expect(entry?.pluginId).toBe("@vendor/demo-runtime"); + }); + it("uses the provided env for external catalog path resolution", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-home-")); const catalogPath = path.join(home, "catalog.json"); diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index 3d3929ec878..9f584494fba 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -1,8 +1,36 @@ -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { setDefaultChannelPluginRegistryForTests } from "./channel-test-helpers.js"; import { configMocks, offsetMocks } from "./channels.mock-harness.js"; +import { + ensureOnboardingPluginInstalled, + loadOnboardingPluginRegistrySnapshotForChannel, +} from "./onboarding/plugin-install.js"; import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; +const catalogMocks = vi.hoisted(() => ({ + listChannelPluginCatalogEntries: vi.fn((): ChannelPluginCatalogEntry[] => []), +})); + +vi.mock("../channels/plugins/catalog.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listChannelPluginCatalogEntries: catalogMocks.listChannelPluginCatalogEntries, + }; +}); + +vi.mock("./onboarding/plugin-install.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ensureOnboardingPluginInstalled: vi.fn(async ({ cfg }) => ({ cfg, installed: true })), + loadOnboardingPluginRegistrySnapshotForChannel: vi.fn(() => createTestRegistry()), + }; +}); + const runtime = createTestRuntime(); let channelsAddCommand: typeof import("./channels.js").channelsAddCommand; @@ -18,6 +46,15 @@ describe("channelsAddCommand", () => { runtime.log.mockClear(); runtime.error.mockClear(); runtime.exit.mockClear(); + catalogMocks.listChannelPluginCatalogEntries.mockClear(); + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([]); + vi.mocked(ensureOnboardingPluginInstalled).mockClear(); + vi.mocked(ensureOnboardingPluginInstalled).mockImplementation(async ({ cfg }) => ({ + cfg, + installed: true, + })); + vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockClear(); + vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockReturnValue(createTestRegistry()); setDefaultChannelPluginRegistryForTests(); }); @@ -59,4 +96,149 @@ describe("channelsAddCommand", () => { expect(offsetMocks.deleteTelegramUpdateOffset).not.toHaveBeenCalled(); }); + + it("falls back to a scoped snapshot after installing an external channel plugin", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); + setActivePluginRegistry(createTestRegistry()); + const catalogEntry: ChannelPluginCatalogEntry = { + id: "msteams", + pluginId: "@openclaw/msteams-plugin", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams", + docsPath: "/channels/msteams", + blurb: "teams channel", + }, + install: { + npmSpec: "@openclaw/msteams", + }, + }; + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]); + const scopedMSTeamsPlugin = { + ...createChannelTestPluginBase({ + id: "msteams", + label: "Microsoft Teams", + docsPath: "/channels/msteams", + }), + setup: { + applyAccountConfig: vi.fn(({ cfg, input }) => ({ + ...cfg, + channels: { + ...cfg.channels, + msteams: { + enabled: true, + tenantId: input.token, + }, + }, + })), + }, + }; + vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockReturnValue( + createTestRegistry([{ pluginId: "msteams", plugin: scopedMSTeamsPlugin, source: "test" }]), + ); + + await channelsAddCommand( + { + channel: "msteams", + account: "default", + token: "tenant-scoped", + }, + runtime, + { hasFlags: true }, + ); + + expect(ensureOnboardingPluginInstalled).toHaveBeenCalledWith( + expect.objectContaining({ entry: catalogEntry }), + ); + expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "msteams", + pluginId: "@openclaw/msteams-plugin", + }), + ); + expect(configMocks.writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + channels: { + msteams: { + enabled: true, + tenantId: "tenant-scoped", + }, + }, + }), + ); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); + + it("uses the installed plugin id when channel and plugin ids differ", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); + setActivePluginRegistry(createTestRegistry()); + const catalogEntry: ChannelPluginCatalogEntry = { + id: "msteams", + pluginId: "@openclaw/msteams-plugin", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams", + docsPath: "/channels/msteams", + blurb: "teams channel", + }, + install: { + npmSpec: "@openclaw/msteams", + }, + }; + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]); + vi.mocked(ensureOnboardingPluginInstalled).mockImplementation(async ({ cfg }) => ({ + cfg, + installed: true, + pluginId: "@vendor/teams-runtime", + })); + vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockReturnValue( + createTestRegistry([ + { + pluginId: "@vendor/teams-runtime", + plugin: { + ...createChannelTestPluginBase({ + id: "msteams", + label: "Microsoft Teams", + docsPath: "/channels/msteams", + }), + setup: { + applyAccountConfig: vi.fn(({ cfg, input }) => ({ + ...cfg, + channels: { + ...cfg.channels, + msteams: { + enabled: true, + tenantId: input.token, + }, + }, + })), + }, + }, + source: "test", + }, + ]), + ); + + await channelsAddCommand( + { + channel: "msteams", + account: "default", + token: "tenant-scoped", + }, + runtime, + { hasFlags: true }, + ); + + expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "msteams", + pluginId: "@vendor/teams-runtime", + }), + ); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/channels/add-mutators.ts b/src/commands/channels/add-mutators.ts index cb2256bd5ac..1943dd99226 100644 --- a/src/commands/channels/add-mutators.ts +++ b/src/commands/channels/add-mutators.ts @@ -1,5 +1,5 @@ import { getChannelPlugin } from "../../channels/plugins/index.js"; -import type { ChannelId, ChannelSetupInput } from "../../channels/plugins/types.js"; +import type { ChannelId, ChannelPlugin, ChannelSetupInput } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { normalizeAccountId } from "../../routing/session-key.js"; @@ -10,9 +10,10 @@ export function applyAccountName(params: { channel: ChatChannel; accountId: string; name?: string; + plugin?: ChannelPlugin; }): OpenClawConfig { const accountId = normalizeAccountId(params.accountId); - const plugin = getChannelPlugin(params.channel); + const plugin = params.plugin ?? getChannelPlugin(params.channel); const apply = plugin?.setup?.applyAccountName; return apply ? apply({ cfg: params.cfg, accountId, name: params.name }) : params.cfg; } @@ -22,9 +23,10 @@ export function applyChannelAccountConfig(params: { channel: ChatChannel; accountId: string; input: ChannelSetupInput; + plugin?: ChannelPlugin; }): OpenClawConfig { const accountId = normalizeAccountId(params.accountId); - const plugin = getChannelPlugin(params.channel); + const plugin = params.plugin ?? getChannelPlugin(params.channel); const apply = plugin?.setup?.applyAccountConfig; if (!apply) { return params.cfg; diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 52a358f4946..e412c60215a 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -3,7 +3,7 @@ import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog. import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js"; -import type { ChannelId, ChannelSetupInput } from "../../channels/plugins/types.js"; +import type { ChannelId, ChannelPlugin, ChannelSetupInput } from "../../channels/plugins/types.js"; import { writeConfigFile, type OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; @@ -55,6 +55,7 @@ export async function channelsAddCommand( const prompter = createClackPrompter(); let selection: ChannelChoice[] = []; const accountIds: Partial> = {}; + const resolvedPlugins = new Map(); await prompter.intro("Channel setup"); let nextConfig = await setupChannels(cfg, runtime, prompter, { allowDisable: false, @@ -66,6 +67,9 @@ export async function channelsAddCommand( onAccountId: (channel, accountId) => { accountIds[channel] = accountId; }, + onResolvedPlugin: (channel, plugin) => { + resolvedPlugins.set(channel, plugin); + }, }); if (selection.length === 0) { await prompter.outro("No channels selected."); @@ -79,7 +83,7 @@ export async function channelsAddCommand( if (wantsNames) { for (const channel of selection) { const accountId = accountIds[channel] ?? DEFAULT_ACCOUNT_ID; - const plugin = getChannelPlugin(channel); + const plugin = resolvedPlugins.get(channel) ?? getChannelPlugin(channel); const account = plugin?.config.resolveAccount(nextConfig, accountId) as | { name?: string } | undefined; @@ -95,6 +99,7 @@ export async function channelsAddCommand( channel, accountId, name, + plugin, }); } } @@ -170,12 +175,33 @@ export async function channelsAddCommand( const rawChannel = String(opts.channel ?? ""); let channel = normalizeChannelId(rawChannel); let catalogEntry = channel ? undefined : resolveCatalogChannelEntry(rawChannel, nextConfig); + const resolveWorkspaceDir = () => + resolveAgentWorkspaceDir(nextConfig, resolveDefaultAgentId(nextConfig)); + // May trigger loadOpenClawPlugins on cache miss (disk scan + jiti import) + const loadScopedPlugin = async ( + channelId: ChannelId, + pluginId?: string, + ): Promise => { + const existing = getChannelPlugin(channelId); + if (existing) { + return existing; + } + const { loadOnboardingPluginRegistrySnapshotForChannel } = + await import("../onboarding/plugin-install.js"); + const snapshot = loadOnboardingPluginRegistrySnapshotForChannel({ + cfg: nextConfig, + runtime, + channel: channelId, + ...(pluginId ? { pluginId } : {}), + workspaceDir: resolveWorkspaceDir(), + }); + return snapshot.channels.find((entry) => entry.plugin.id === channelId)?.plugin; + }; if (!channel && catalogEntry) { - const { ensureOnboardingPluginInstalled, reloadOnboardingPluginRegistry } = - await import("../onboarding/plugin-install.js"); + const { ensureOnboardingPluginInstalled } = await import("../onboarding/plugin-install.js"); const prompter = createClackPrompter(); - const workspaceDir = resolveAgentWorkspaceDir(nextConfig, resolveDefaultAgentId(nextConfig)); + const workspaceDir = resolveWorkspaceDir(); const result = await ensureOnboardingPluginInstalled({ cfg: nextConfig, entry: catalogEntry, @@ -187,7 +213,10 @@ export async function channelsAddCommand( if (!result.installed) { return; } - reloadOnboardingPluginRegistry({ cfg: nextConfig, runtime, workspaceDir }); + catalogEntry = { + ...catalogEntry, + ...(result.pluginId ? { pluginId: result.pluginId } : {}), + }; channel = normalizeChannelId(catalogEntry.id) ?? (catalogEntry.id as ChannelId); } @@ -200,7 +229,7 @@ export async function channelsAddCommand( return; } - const plugin = getChannelPlugin(channel); + const plugin = await loadScopedPlugin(channel, catalogEntry?.pluginId); if (!plugin?.setup?.applyAccountConfig) { runtime.error(`Channel ${channel} does not support add.`); runtime.exit(1); @@ -294,6 +323,7 @@ export async function channelsAddCommand( channel, accountId, input, + plugin, }); if (channel === "telegram" && resolveTelegramAccount) { diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index b25bf35db78..6c505c6d4e2 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js"; import type { OpenClawConfig } from "../config/config.js"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; @@ -8,8 +9,16 @@ import { setDefaultChannelPluginRegistryForTests, } from "./channel-test-helpers.js"; import { setupChannels } from "./onboard-channels.js"; +import { + loadOnboardingPluginRegistrySnapshotForChannel, + reloadOnboardingPluginRegistry, +} from "./onboarding/plugin-install.js"; import { createExitThrowingRuntime, createWizardPrompter } from "./test-wizard-helpers.js"; +const catalogMocks = vi.hoisted(() => ({ + listChannelPluginCatalogEntries: vi.fn(), +})); + function createPrompter(overrides: Partial): WizardPrompter { return createWizardPrompter( { @@ -174,6 +183,20 @@ vi.mock("../channel-web.js", () => ({ loginWeb: vi.fn(async () => {}), })); +vi.mock("../channels/plugins/catalog.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listChannelPluginCatalogEntries: ((...args) => { + const implementation = catalogMocks.listChannelPluginCatalogEntries.getMockImplementation(); + if (implementation) { + return catalogMocks.listChannelPluginCatalogEntries(...args); + } + return actual.listChannelPluginCatalogEntries(...args); + }) as typeof actual.listChannelPluginCatalogEntries, + }; +}); + vi.mock("./onboard-helpers.js", () => ({ detectBinary: vi.fn(async () => false), })); @@ -183,6 +206,7 @@ vi.mock("./onboarding/plugin-install.js", async (importOriginal) => { return { ...(actual as Record), // Allow tests to simulate an empty plugin registry during onboarding. + loadOnboardingPluginRegistrySnapshotForChannel: vi.fn(() => createEmptyPluginRegistry()), reloadOnboardingPluginRegistry: vi.fn(() => {}), }; }); @@ -190,6 +214,9 @@ vi.mock("./onboarding/plugin-install.js", async (importOriginal) => { describe("setupChannels", () => { beforeEach(() => { setDefaultChannelPluginRegistryForTests(); + catalogMocks.listChannelPluginCatalogEntries.mockReset(); + vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockClear(); + vi.mocked(reloadOnboardingPluginRegistry).mockClear(); }); it("QuickStart uses single-select (no multiselect) and doesn't prompt for Telegram token when WhatsApp is chosen", async () => { const select = vi.fn(async () => "whatsapp"); @@ -257,6 +284,12 @@ describe("setupChannels", () => { ); }); expect(sawHardStop).toBe(false); + expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + }), + ); + expect(reloadOnboardingPluginRegistry).not.toHaveBeenCalled(); }); it("shows explicit dmScope config command in channel primer", async () => { @@ -282,6 +315,243 @@ describe("setupChannels", () => { expect(multiselect).not.toHaveBeenCalled(); }); + it("keeps configured external plugin channels visible when the active registry starts empty", async () => { + setActivePluginRegistry(createEmptyPluginRegistry()); + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "msteams", + pluginId: "@openclaw/msteams-plugin", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams", + docsPath: "/channels/msteams", + blurb: "teams channel", + }, + install: { + npmSpec: "@openclaw/msteams", + }, + } satisfies ChannelPluginCatalogEntry, + ]); + vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockImplementation( + ({ channel }: { channel: string }) => { + const registry = createEmptyPluginRegistry(); + if (channel === "msteams") { + registry.channels.push({ + pluginId: "@openclaw/msteams-plugin", + source: "test", + plugin: { + id: "msteams", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams", + docsPath: "/channels/msteams", + blurb: "teams channel", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, + } as never); + } + return registry; + }, + ); + const select = vi.fn(async ({ message, options }: { message: string; options: unknown[] }) => { + if (message === "Select a channel") { + const entries = options as Array<{ value: string; hint?: string }>; + const msteams = entries.find((entry) => entry.value === "msteams"); + expect(msteams).toBeDefined(); + expect(msteams?.hint ?? "").not.toContain("plugin"); + expect(msteams?.hint ?? "").not.toContain("install"); + return "__done__"; + } + return "__done__"; + }); + const { multiselect, text } = createUnexpectedPromptGuards(); + const prompter = createPrompter({ + select: select as unknown as WizardPrompter["select"], + multiselect, + text, + }); + + await runSetupChannels( + { + channels: { + msteams: { + tenantId: "tenant-1", + }, + }, + plugins: { + entries: { + "@openclaw/msteams-plugin": { enabled: true }, + }, + }, + } as OpenClawConfig, + prompter, + ); + + expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "msteams", + pluginId: "@openclaw/msteams-plugin", + }), + ); + expect(multiselect).not.toHaveBeenCalled(); + }); + + it("uses scoped plugin accounts when disabling a configured external channel", async () => { + setActivePluginRegistry(createEmptyPluginRegistry()); + const setAccountEnabled = vi.fn( + ({ + cfg, + accountId, + enabled, + }: { + cfg: OpenClawConfig; + accountId: string; + enabled: boolean; + }) => ({ + ...cfg, + channels: { + ...cfg.channels, + msteams: { + ...(cfg.channels?.msteams as Record | undefined), + accounts: { + ...(cfg.channels?.msteams as { accounts?: Record } | undefined) + ?.accounts, + [accountId]: { + ...( + cfg.channels?.msteams as + | { + accounts?: Record>; + } + | undefined + )?.accounts?.[accountId], + enabled, + }, + }, + }, + }, + }), + ); + vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockImplementation( + ({ channel }: { channel: string }) => { + const registry = createEmptyPluginRegistry(); + if (channel === "msteams") { + registry.channels.push({ + pluginId: "msteams", + source: "test", + plugin: { + id: "msteams", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams", + docsPath: "/channels/msteams", + blurb: "teams channel", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: (cfg: OpenClawConfig) => + Object.keys( + (cfg.channels?.msteams as { accounts?: Record } | undefined) + ?.accounts ?? {}, + ), + resolveAccount: (cfg: OpenClawConfig, accountId: string) => + ( + cfg.channels?.msteams as + | { + accounts?: Record>; + } + | undefined + )?.accounts?.[accountId] ?? { accountId }, + setAccountEnabled, + }, + onboarding: { + getStatus: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ + channel: "msteams", + configured: Boolean( + (cfg.channels?.msteams as { tenantId?: string } | undefined)?.tenantId, + ), + statusLines: [], + selectionHint: "configured", + })), + }, + outbound: { deliveryMode: "direct" }, + }, + } as never); + } + return registry; + }, + ); + + let channelSelectionCount = 0; + const select = vi.fn(async ({ message, options }: { message: string; options: unknown[] }) => { + if (message === "Select a channel") { + channelSelectionCount += 1; + return channelSelectionCount === 1 ? "msteams" : "__done__"; + } + if (message.includes("already configured")) { + return "disable"; + } + if (message === "Microsoft Teams account") { + const accountOptions = options as Array<{ value: string; label: string }>; + expect(accountOptions.map((option) => option.value)).toEqual(["default", "work"]); + return "work"; + } + return "__done__"; + }); + const { multiselect, text } = createUnexpectedPromptGuards(); + const prompter = createPrompter({ + select: select as unknown as WizardPrompter["select"], + multiselect, + text, + }); + + const next = await runSetupChannels( + { + channels: { + msteams: { + tenantId: "tenant-1", + accounts: { + default: { enabled: true }, + work: { enabled: true }, + }, + }, + }, + plugins: { + entries: { + msteams: { enabled: true }, + }, + }, + } as OpenClawConfig, + prompter, + { allowDisable: true }, + ); + + expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ channel: "msteams" }), + ); + expect(setAccountEnabled).toHaveBeenCalledWith( + expect.objectContaining({ accountId: "work", enabled: false }), + ); + expect( + ( + next.channels?.msteams as + | { + accounts?: Record; + } + | undefined + )?.accounts?.work?.enabled, + ).toBe(false); + expect(multiselect).not.toHaveBeenCalled(); + }); + it("prompts for configured channel action and skips configuration when told to skip", async () => { const select = createQuickstartTelegramSelect({ configuredAction: "skip", diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index ca4b090ce5a..4a313ebf913 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -5,7 +5,7 @@ import { getChannelSetupPlugin, listChannelSetupPlugins, } from "../channels/plugins/setup-registry.js"; -import type { ChannelMeta } from "../channels/plugins/types.js"; +import type { ChannelMeta, ChannelPlugin } from "../channels/plugins/types.js"; import { formatChannelPrimerLine, formatChannelSelectionLine, @@ -23,13 +23,14 @@ import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; import type { ChannelChoice } from "./onboard-types.js"; import { ensureOnboardingPluginInstalled, - reloadOnboardingPluginRegistry, + loadOnboardingPluginRegistrySnapshotForChannel, } from "./onboarding/plugin-install.js"; import { getChannelOnboardingAdapter, listChannelOnboardingAdapters, } from "./onboarding/registry.js"; import type { + ChannelOnboardingAdapter, ChannelOnboardingConfiguredResult, ChannelOnboardingDmPolicy, ChannelOnboardingResult, @@ -91,9 +92,10 @@ async function promptRemovalAccountId(params: { prompter: WizardPrompter; label: string; channel: ChannelChoice; + plugin?: ChannelPlugin; }): Promise { const { cfg, prompter, label, channel } = params; - const plugin = getChannelSetupPlugin(channel); + const plugin = params.plugin ?? getChannelSetupPlugin(channel); if (!plugin) { return DEFAULT_ACCOUNT_ID; } @@ -117,8 +119,9 @@ async function collectChannelStatus(params: { cfg: OpenClawConfig; options?: SetupChannelsOptions; accountOverrides: Partial>; + installedPlugins?: ReturnType; }): Promise { - const installedPlugins = listChannelSetupPlugins(); + const installedPlugins = params.installedPlugins ?? listChannelSetupPlugins(); const installedIds = new Set(installedPlugins.map((plugin) => plugin.id)); const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); const catalogEntries = listChannelPluginCatalogEntries({ workspaceDir }).filter( @@ -230,10 +233,12 @@ async function maybeConfigureDmPolicies(params: { selection: ChannelChoice[]; prompter: WizardPrompter; accountIdsByChannel?: Map; + resolveAdapter?: (channel: ChannelChoice) => ChannelOnboardingAdapter | undefined; }): Promise { const { selection, prompter, accountIdsByChannel } = params; + const resolve = params.resolveAdapter ?? getChannelOnboardingAdapter; const dmPolicies = selection - .map((channel) => getChannelOnboardingAdapter(channel)?.dmPolicy) + .map((channel) => resolve(channel)?.dmPolicy) .filter(Boolean) as ChannelOnboardingDmPolicy[]; if (dmPolicies.length === 0) { return params.cfg; @@ -300,23 +305,85 @@ export async function setupChannels( options?: SetupChannelsOptions, ): Promise { let next = cfg; - if (listChannelOnboardingAdapters().length === 0) { - reloadOnboardingPluginRegistry({ - cfg: next, - runtime, - workspaceDir: resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next)), - }); - } const forceAllowFromChannels = new Set(options?.forceAllowFromChannels ?? []); const accountOverrides: Partial> = { ...options?.accountIds, }; + const scopedPluginsById = new Map(); + const resolveWorkspaceDir = () => resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next)); + const rememberScopedPlugin = (plugin: ChannelPlugin) => { + const channel = plugin.id; + scopedPluginsById.set(channel, plugin); + options?.onResolvedPlugin?.(channel, plugin); + }; + const getVisibleChannelPlugin = (channel: ChannelChoice): ChannelPlugin | undefined => + scopedPluginsById.get(channel) ?? getChannelSetupPlugin(channel); + const listVisibleInstalledPlugins = (): ChannelPlugin[] => { + const merged = new Map(); + for (const plugin of listChannelSetupPlugins()) { + merged.set(plugin.id, plugin); + } + for (const plugin of scopedPluginsById.values()) { + merged.set(plugin.id, plugin); + } + return Array.from(merged.values()); + }; + const loadScopedChannelPlugin = ( + channel: ChannelChoice, + pluginId?: string, + ): ChannelPlugin | undefined => { + const existing = getVisibleChannelPlugin(channel); + if (existing) { + return existing; + } + const snapshot = loadOnboardingPluginRegistrySnapshotForChannel({ + cfg: next, + runtime, + channel, + ...(pluginId ? { pluginId } : {}), + workspaceDir: resolveWorkspaceDir(), + }); + const plugin = snapshot.channels.find((entry) => entry.plugin.id === channel)?.plugin; + if (plugin) { + rememberScopedPlugin(plugin); + } + return plugin; + }; + const getVisibleOnboardingAdapter = (channel: ChannelChoice) => { + const adapter = getChannelOnboardingAdapter(channel); + if (adapter) { + return adapter; + } + return scopedPluginsById.get(channel)?.onboarding; + }; + const preloadConfiguredExternalPlugins = () => { + // Keep onboarding memory bounded by snapshot-loading only configured external plugins. + const workspaceDir = resolveWorkspaceDir(); + for (const entry of listChannelPluginCatalogEntries({ workspaceDir })) { + const channel = entry.id as ChannelChoice; + if (getVisibleChannelPlugin(channel)) { + continue; + } + const explicitlyEnabled = + next.plugins?.entries?.[entry.pluginId ?? channel]?.enabled === true; + if (!explicitlyEnabled && !isChannelConfigured(next, channel)) { + continue; + } + loadScopedChannelPlugin(channel, entry.pluginId); + } + }; if (options?.whatsappAccountId?.trim()) { accountOverrides.whatsapp = options.whatsappAccountId.trim(); } + preloadConfiguredExternalPlugins(); const { installedPlugins, catalogEntries, statusByChannel, statusLines } = - await collectChannelStatus({ cfg: next, options, accountOverrides }); + await collectChannelStatus({ + cfg: next, + options, + accountOverrides, + installedPlugins: listVisibleInstalledPlugins(), + }); if (!options?.skipStatusNote && statusLines.length > 0) { await prompter.note(statusLines.join("\n"), "Channel status"); } @@ -363,7 +430,7 @@ export async function setupChannels( const accountIdsByChannel = new Map(); const recordAccount = (channel: ChannelChoice, accountId: string) => { options?.onAccountId?.(channel, accountId); - const adapter = getChannelOnboardingAdapter(channel); + const adapter = getVisibleOnboardingAdapter(channel); adapter?.onAccountRecorded?.(accountId, options); accountIdsByChannel.set(channel, accountId); }; @@ -376,7 +443,6 @@ export async function setupChannels( }; const resolveDisabledHint = (channel: ChannelChoice): string | undefined => { - const plugin = getChannelSetupPlugin(channel); if ( typeof (next.channels as Record | undefined)?.[channel] ?.enabled === "boolean" @@ -385,6 +451,7 @@ export async function setupChannels( ? "disabled" : undefined; } + const plugin = getVisibleChannelPlugin(channel); if (!plugin) { if (next.plugins?.entries?.[channel]?.enabled === false) { return "plugin disabled"; @@ -424,9 +491,9 @@ export async function setupChannels( const getChannelEntries = () => { const core = listChatChannels(); - const installed = listChannelSetupPlugins(); + const installed = listVisibleInstalledPlugins(); const installedIds = new Set(installed.map((plugin) => plugin.id)); - const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next)); + const workspaceDir = resolveWorkspaceDir(); const catalog = listChannelPluginCatalogEntries({ workspaceDir }).filter( (entry) => !installedIds.has(entry.id), ); @@ -454,7 +521,7 @@ export async function setupChannels( }; const refreshStatus = async (channel: ChannelChoice) => { - const adapter = getChannelOnboardingAdapter(channel); + const adapter = getVisibleOnboardingAdapter(channel); if (!adapter) { return; } @@ -463,6 +530,10 @@ export async function setupChannels( }; const enableBundledPluginForSetup = async (channel: ChannelChoice): Promise => { + if (getVisibleChannelPlugin(channel)) { + await refreshStatus(channel); + return true; + } const result = enablePluginInConfig(next, channel); next = result.config; if (!result.enabled) { @@ -472,12 +543,22 @@ export async function setupChannels( ); return false; } - const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next)); - reloadOnboardingPluginRegistry({ - cfg: next, - runtime, - workspaceDir, - }); + const adapter = getVisibleOnboardingAdapter(channel); + const plugin = loadScopedChannelPlugin(channel); + if (!plugin) { + if (adapter) { + await prompter.note( + `${channel} plugin not available (continuing with onboarding). If the channel still doesn't work after setup, run \`${formatCliCommand( + "openclaw plugins list", + )}\` and \`${formatCliCommand("openclaw plugins enable " + channel)}\`, then restart the gateway.`, + "Channel setup", + ); + await refreshStatus(channel); + return true; + } + await prompter.note(`${channel} plugin not available.`, "Channel setup"); + return false; + } await refreshStatus(channel); return true; }; @@ -503,7 +584,7 @@ export async function setupChannels( }; const configureChannel = async (channel: ChannelChoice) => { - const adapter = getChannelOnboardingAdapter(channel); + const adapter = getVisibleOnboardingAdapter(channel); if (!adapter) { await prompter.note(`${channel} does not support onboarding yet.`, "Channel setup"); return; @@ -521,8 +602,8 @@ export async function setupChannels( }; const handleConfiguredChannel = async (channel: ChannelChoice, label: string) => { - const plugin = getChannelSetupPlugin(channel); - const adapter = getChannelOnboardingAdapter(channel); + const plugin = getVisibleChannelPlugin(channel); + const adapter = getVisibleOnboardingAdapter(channel); if (adapter?.configureWhenConfigured) { const custom = await adapter.configureWhenConfigured({ cfg: next, @@ -577,6 +658,7 @@ export async function setupChannels( prompter, label, channel, + plugin, }) : DEFAULT_ACCOUNT_ID; const resolvedAccountId = @@ -615,7 +697,7 @@ export async function setupChannels( const { catalogById } = getChannelEntries(); const catalogEntry = catalogById.get(channel); if (catalogEntry) { - const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next)); + const workspaceDir = resolveWorkspaceDir(); const result = await ensureOnboardingPluginInstalled({ cfg: next, entry: catalogEntry, @@ -627,11 +709,7 @@ export async function setupChannels( if (!result.installed) { return; } - reloadOnboardingPluginRegistry({ - cfg: next, - runtime, - workspaceDir, - }); + loadScopedChannelPlugin(channel, result.pluginId ?? catalogEntry.pluginId); await refreshStatus(channel); } else { const enabled = await enableBundledPluginForSetup(channel); @@ -640,8 +718,8 @@ export async function setupChannels( } } - const plugin = getChannelSetupPlugin(channel); - const adapter = getChannelOnboardingAdapter(channel); + const plugin = getVisibleChannelPlugin(channel); + const adapter = getVisibleOnboardingAdapter(channel); const label = plugin?.meta.label ?? catalogEntry?.meta.label ?? channel; const status = statusByChannel.get(channel); const configured = status?.configured ?? false; @@ -730,6 +808,7 @@ export async function setupChannels( selection, prompter, accountIdsByChannel, + resolveAdapter: getVisibleOnboardingAdapter, }); } diff --git a/src/commands/onboarding/plugin-install.test.ts b/src/commands/onboarding/plugin-install.test.ts index 2be78d9a6fc..d2c55d330c7 100644 --- a/src/commands/onboarding/plugin-install.test.ts +++ b/src/commands/onboarding/plugin-install.test.ts @@ -58,15 +58,20 @@ import fs from "node:fs"; import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js"; import type { OpenClawConfig } from "../../config/config.js"; import { loadOpenClawPlugins } from "../../plugins/loader.js"; +import { createEmptyPluginRegistry } from "../../plugins/registry.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; import { makePrompter, makeRuntime } from "./__tests__/test-utils.js"; import { ensureOnboardingPluginInstalled, + loadOnboardingPluginRegistrySnapshotForChannel, reloadOnboardingPluginRegistry, + reloadOnboardingPluginRegistryForChannel, } from "./plugin-install.js"; const baseEntry: ChannelPluginCatalogEntry = { id: "zalo", + pluginId: "zalo", meta: { id: "zalo", label: "Zalo", @@ -84,6 +89,7 @@ const baseEntry: ChannelPluginCatalogEntry = { beforeEach(() => { vi.clearAllMocks(); resolveBundledPluginSources.mockReturnValue(new Map()); + setActivePluginRegistry(createEmptyPluginRegistry()); }); function mockRepoLocalPathExists() { @@ -171,6 +177,30 @@ describe("ensureOnboardingPluginInstalled", () => { expect(result.cfg.plugins?.entries?.zalo?.enabled).toBe(true); }); + it("uses the catalog plugin id for local-path installs", async () => { + const runtime = makeRuntime(); + const prompter = makePrompter({ + select: vi.fn(async () => "local") as WizardPrompter["select"], + }); + const cfg: OpenClawConfig = {}; + mockRepoLocalPathExists(); + + const result = await ensureOnboardingPluginInstalled({ + cfg, + entry: { + ...baseEntry, + id: "teams", + pluginId: "@openclaw/msteams-plugin", + }, + prompter, + runtime, + }); + + expect(result.installed).toBe(true); + expect(result.pluginId).toBe("@openclaw/msteams-plugin"); + expect(result.cfg.plugins?.entries?.["@openclaw/msteams-plugin"]?.enabled).toBe(true); + }); + it("defaults to local on dev channel when local path exists", async () => { expect(await runInitialValueForChannel("dev")).toBe("local"); }); @@ -268,4 +298,109 @@ describe("ensureOnboardingPluginInstalled", () => { vi.mocked(loadOpenClawPlugins).mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, ); }); + + it("scopes channel reloads when onboarding starts from an empty registry", () => { + const runtime = makeRuntime(); + const cfg: OpenClawConfig = {}; + + reloadOnboardingPluginRegistryForChannel({ + cfg, + runtime, + channel: "telegram", + workspaceDir: "/tmp/openclaw-workspace", + }); + + expect(loadOpenClawPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config: cfg, + workspaceDir: "/tmp/openclaw-workspace", + cache: false, + onlyPluginIds: ["telegram"], + }), + ); + }); + + it("keeps full reloads when the active plugin registry is already populated", () => { + const runtime = makeRuntime(); + const cfg: OpenClawConfig = {}; + const registry = createEmptyPluginRegistry(); + registry.plugins.push({ + id: "loaded", + name: "loaded", + source: "/tmp/loaded.cjs", + origin: "bundled", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: true, + }); + setActivePluginRegistry(registry); + + reloadOnboardingPluginRegistryForChannel({ + cfg, + runtime, + channel: "telegram", + workspaceDir: "/tmp/openclaw-workspace", + }); + + expect(loadOpenClawPlugins).toHaveBeenCalledWith( + expect.not.objectContaining({ + onlyPluginIds: expect.anything(), + }), + ); + }); + + it("can load a channel-scoped snapshot without activating the global registry", () => { + const runtime = makeRuntime(); + const cfg: OpenClawConfig = {}; + + loadOnboardingPluginRegistrySnapshotForChannel({ + cfg, + runtime, + channel: "telegram", + workspaceDir: "/tmp/openclaw-workspace", + }); + + expect(loadOpenClawPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config: cfg, + workspaceDir: "/tmp/openclaw-workspace", + cache: false, + onlyPluginIds: ["telegram"], + activate: false, + }), + ); + }); + + it("scopes snapshots by plugin id when channel and plugin ids differ", () => { + const runtime = makeRuntime(); + const cfg: OpenClawConfig = {}; + + loadOnboardingPluginRegistrySnapshotForChannel({ + cfg, + runtime, + channel: "msteams", + pluginId: "@openclaw/msteams-plugin", + workspaceDir: "/tmp/openclaw-workspace", + }); + + expect(loadOpenClawPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config: cfg, + workspaceDir: "/tmp/openclaw-workspace", + cache: false, + onlyPluginIds: ["@openclaw/msteams-plugin"], + activate: false, + }), + ); + }); }); diff --git a/src/commands/onboarding/plugin-install.ts b/src/commands/onboarding/plugin-install.ts index b4aabc06646..31f5ec1d64d 100644 --- a/src/commands/onboarding/plugin-install.ts +++ b/src/commands/onboarding/plugin-install.ts @@ -15,6 +15,8 @@ import { installPluginFromNpmSpec } from "../../plugins/install.js"; import { buildNpmResolutionInstallFields, recordPluginInstall } from "../../plugins/installs.js"; import { loadOpenClawPlugins } from "../../plugins/loader.js"; import { createPluginLoaderLogger } from "../../plugins/logger.js"; +import type { PluginRegistry } from "../../plugins/registry.js"; +import { getActivePluginRegistry } from "../../plugins/runtime.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; @@ -23,6 +25,7 @@ type InstallChoice = "npm" | "local" | "skip"; type InstallResult = { cfg: OpenClawConfig; installed: boolean; + pluginId?: string; }; function hasGitWorkspace(workspaceDir?: string): boolean { @@ -174,8 +177,9 @@ export async function ensureOnboardingPluginInstalled(params: { if (choice === "local" && localPath) { next = addPluginLoadPath(next, localPath); - next = enablePluginInConfig(next, entry.id).config; - return { cfg: next, installed: true }; + const pluginId = entry.pluginId ?? entry.id; + next = enablePluginInConfig(next, pluginId).config; + return { cfg: next, installed: true, pluginId }; } const result = await installPluginFromNpmSpec({ @@ -196,7 +200,7 @@ export async function ensureOnboardingPluginInstalled(params: { version: result.version, ...buildNpmResolutionInstallFields(result.npmResolution), }); - return { cfg: next, installed: true }; + return { cfg: next, installed: true, pluginId: result.pluginId }; } await prompter.note( @@ -211,8 +215,9 @@ export async function ensureOnboardingPluginInstalled(params: { }); if (fallback) { next = addPluginLoadPath(next, localPath); - next = enablePluginInConfig(next, entry.id).config; - return { cfg: next, installed: true }; + const pluginId = entry.pluginId ?? entry.id; + next = enablePluginInConfig(next, pluginId).config; + return { cfg: next, installed: true, pluginId }; } } @@ -225,14 +230,59 @@ export function reloadOnboardingPluginRegistry(params: { runtime: RuntimeEnv; workspaceDir?: string; }): void { + loadOnboardingPluginRegistry(params); +} + +function loadOnboardingPluginRegistry(params: { + cfg: OpenClawConfig; + runtime: RuntimeEnv; + workspaceDir?: string; + onlyPluginIds?: string[]; + activate?: boolean; +}): PluginRegistry { clearPluginDiscoveryCache(); const workspaceDir = params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); const log = createSubsystemLogger("plugins"); - loadOpenClawPlugins({ + return loadOpenClawPlugins({ config: params.cfg, workspaceDir, cache: false, logger: createPluginLoaderLogger(log), + onlyPluginIds: params.onlyPluginIds, + activate: params.activate, + }); +} + +export function reloadOnboardingPluginRegistryForChannel(params: { + cfg: OpenClawConfig; + runtime: RuntimeEnv; + channel: string; + pluginId?: string; + workspaceDir?: string; +}): void { + const activeRegistry = getActivePluginRegistry(); + // On low-memory hosts, the empty-registry fallback should only recover the selected + // plugin instead of importing every bundled extension during onboarding. + const onlyPluginIds = activeRegistry?.plugins.length + ? undefined + : [params.pluginId ?? params.channel]; + loadOnboardingPluginRegistry({ + ...params, + onlyPluginIds, + }); +} + +export function loadOnboardingPluginRegistrySnapshotForChannel(params: { + cfg: OpenClawConfig; + runtime: RuntimeEnv; + channel: string; + pluginId?: string; + workspaceDir?: string; +}): PluginRegistry { + return loadOnboardingPluginRegistry({ + ...params, + onlyPluginIds: [params.pluginId ?? params.channel], + activate: false, }); } diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts index d8825abc853..6d31199ea2a 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/onboarding/registry.ts @@ -1,8 +1,23 @@ +import { discordOnboardingAdapter } from "../../../extensions/discord/src/onboarding.js"; +import { imessageOnboardingAdapter } from "../../../extensions/imessage/src/onboarding.js"; +import { signalOnboardingAdapter } from "../../../extensions/signal/src/onboarding.js"; +import { slackOnboardingAdapter } from "../../../extensions/slack/src/onboarding.js"; +import { telegramOnboardingAdapter } from "../../../extensions/telegram/src/setup-surface.js"; +import { whatsappOnboardingAdapter } from "../../../extensions/whatsapp/src/onboarding.js"; import { listChannelSetupPlugins } from "../../channels/plugins/setup-registry.js"; import { buildChannelOnboardingAdapterFromSetupWizard } from "../../channels/plugins/setup-wizard.js"; import type { ChannelChoice } from "../onboard-types.js"; import type { ChannelOnboardingAdapter } from "./types.js"; +const BUILTIN_ONBOARDING_ADAPTERS: ChannelOnboardingAdapter[] = [ + telegramOnboardingAdapter, + whatsappOnboardingAdapter, + discordOnboardingAdapter, + slackOnboardingAdapter, + signalOnboardingAdapter, + imessageOnboardingAdapter, +]; + const setupWizardAdapters = new WeakMap(); function resolveChannelOnboardingAdapter( @@ -27,7 +42,9 @@ function resolveChannelOnboardingAdapter( } const CHANNEL_ONBOARDING_ADAPTERS = () => { - const adapters = new Map(); + const adapters = new Map( + BUILTIN_ONBOARDING_ADAPTERS.map((adapter) => [adapter.channel, adapter] as const), + ); for (const plugin of listChannelSetupPlugins()) { const adapter = resolveChannelOnboardingAdapter(plugin); if (!adapter) { diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index 6bc049ff626..91e38a6ae99 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -111,6 +111,29 @@ export type CommandRegistrationResult = { error?: string; }; +/** + * Validate a plugin command definition without registering it. + * Returns an error message if invalid, or null if valid. + * Shared by both the global registration path and snapshot (non-activating) loads. + */ +export function validatePluginCommandDefinition( + command: OpenClawPluginCommandDefinition, +): string | null { + if (typeof command.handler !== "function") { + return "Command handler must be a function"; + } + if (typeof command.name !== "string") { + return "Command name must be a string"; + } + if (typeof command.description !== "string") { + return "Command description must be a string"; + } + if (!command.description.trim()) { + return "Command description cannot be empty"; + } + return validateCommandName(command.name.trim()); +} + /** * Register a plugin command. * Returns an error if the command name is invalid or reserved. @@ -125,28 +148,13 @@ export function registerPluginCommand( return { ok: false, error: "Cannot register commands while processing is in progress" }; } - // Validate handler is a function - if (typeof command.handler !== "function") { - return { ok: false, error: "Command handler must be a function" }; - } - - if (typeof command.name !== "string") { - return { ok: false, error: "Command name must be a string" }; - } - if (typeof command.description !== "string") { - return { ok: false, error: "Command description must be a string" }; + const definitionError = validatePluginCommandDefinition(command); + if (definitionError) { + return { ok: false, error: definitionError }; } const name = command.name.trim(); const description = command.description.trim(); - if (!description) { - return { ok: false, error: "Command description cannot be empty" }; - } - - const validationError = validateCommandName(name); - if (validationError) { - return { ok: false, error: validationError }; - } const key = `/${name.toLowerCase()}`; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index eec2cf4f410..0460e481b25 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -14,15 +14,19 @@ async function importFreshPluginTestModules() { vi.unmock("./hooks.js"); vi.unmock("./loader.js"); vi.unmock("jiti"); - const [loader, hookRunnerGlobal, hooks] = await Promise.all([ + const [loader, hookRunnerGlobal, hooks, runtime, registry] = await Promise.all([ import("./loader.js"), import("./hook-runner-global.js"), import("./hooks.js"), + import("./runtime.js"), + import("./registry.js"), ]); return { ...loader, ...hookRunnerGlobal, ...hooks, + ...runtime, + ...registry, }; } @@ -30,9 +34,13 @@ const { __testing, clearPluginLoaderCache, createHookRunner, + createEmptyPluginRegistry, + getActivePluginRegistry, + getActivePluginRegistryKey, getGlobalHookRunner, loadOpenClawPlugins, resetGlobalHookRunner, + setActivePluginRegistry, } = await importFreshPluginTestModules(); type TempPlugin = { dir: string; file: string; id: string }; @@ -580,6 +588,112 @@ describe("loadOpenClawPlugins", () => { expect(Object.keys(registry.gatewayHandlers)).toContain("allowed.ping"); }); + it("limits imports to the requested plugin ids", () => { + useNoBundledPlugins(); + const allowed = writePlugin({ + id: "allowed", + filename: "allowed.cjs", + body: `module.exports = { id: "allowed", register() {} };`, + }); + const skippedMarker = path.join(makeTempDir(), "skipped-loaded.txt"); + const skipped = writePlugin({ + id: "skipped", + filename: "skipped.cjs", + body: `require("node:fs").writeFileSync(${JSON.stringify(skippedMarker)}, "loaded", "utf-8"); +module.exports = { id: "skipped", register() { throw new Error("skipped plugin should not load"); } };`, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [allowed.file, skipped.file] }, + allow: ["allowed", "skipped"], + }, + }, + onlyPluginIds: ["allowed"], + }); + + expect(registry.plugins.map((entry) => entry.id)).toEqual(["allowed"]); + expect(fs.existsSync(skippedMarker)).toBe(false); + }); + + it("keeps scoped plugin loads in a separate cache entry", () => { + useNoBundledPlugins(); + const allowed = writePlugin({ + id: "allowed", + filename: "allowed.cjs", + body: `module.exports = { id: "allowed", register() {} };`, + }); + const extra = writePlugin({ + id: "extra", + filename: "extra.cjs", + body: `module.exports = { id: "extra", register() {} };`, + }); + const options = { + config: { + plugins: { + load: { paths: [allowed.file, extra.file] }, + allow: ["allowed", "extra"], + }, + }, + }; + + const full = loadOpenClawPlugins(options); + const scoped = loadOpenClawPlugins({ + ...options, + onlyPluginIds: ["allowed"], + }); + const scopedAgain = loadOpenClawPlugins({ + ...options, + onlyPluginIds: ["allowed"], + }); + + expect(full.plugins.map((entry) => entry.id).toSorted()).toEqual(["allowed", "extra"]); + expect(scoped).not.toBe(full); + expect(scoped.plugins.map((entry) => entry.id)).toEqual(["allowed"]); + expect(scopedAgain).toBe(scoped); + }); + + it("can load a scoped registry without replacing the active global registry", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "allowed", + filename: "allowed.cjs", + body: `module.exports = { id: "allowed", register() {} };`, + }); + const previousRegistry = createEmptyPluginRegistry(); + setActivePluginRegistry(previousRegistry, "existing-registry"); + resetGlobalHookRunner(); + + const scoped = loadOpenClawPlugins({ + cache: false, + activate: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["allowed"], + }, + }, + onlyPluginIds: ["allowed"], + }); + + expect(scoped.plugins.map((entry) => entry.id)).toEqual(["allowed"]); + expect(getActivePluginRegistry()).toBe(previousRegistry); + expect(getActivePluginRegistryKey()).toBe("existing-registry"); + expect(getGlobalHookRunner()).toBeNull(); + }); + + it("throws when activate:false is used without cache:false", () => { + expect(() => loadOpenClawPlugins({ activate: false })).toThrow( + "activate:false requires cache:false", + ); + expect(() => loadOpenClawPlugins({ activate: false, cache: true })).toThrow( + "activate:false requires cache:false", + ); + }); + it("re-initializes global hook runner when serving registry from cache", () => { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const plugin = writePlugin({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index b9ebc7f2a1e..b9132c08f33 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -50,6 +50,8 @@ export type PluginLoadOptions = { runtimeOptions?: CreatePluginRuntimeOptions; cache?: boolean; mode?: "full" | "validate"; + onlyPluginIds?: string[]; + activate?: boolean; }; const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 32; @@ -241,6 +243,7 @@ function buildCacheKey(params: { plugins: NormalizedPluginsConfig; installs?: Record; env: NodeJS.ProcessEnv; + onlyPluginIds?: string[]; }): string { const { roots, loadPaths } = resolvePluginCacheInputs({ workspaceDir: params.workspaceDir, @@ -263,11 +266,20 @@ function buildCacheKey(params: { }, ]), ); + const scopeKey = JSON.stringify(params.onlyPluginIds ?? []); return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({ ...params.plugins, installs, loadPaths, - })}`; + })}::${scopeKey}`; +} + +function normalizeScopedPluginIds(ids?: string[]): string[] | undefined { + if (!ids) { + return undefined; + } + const normalized = Array.from(new Set(ids.map((id) => id.trim()).filter(Boolean))).toSorted(); + return normalized.length > 0 ? normalized : undefined; } function validatePluginConfig(params: { @@ -640,6 +652,13 @@ function activatePluginRegistry(registry: PluginRegistry, cacheKey: string): voi } export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry { + // Snapshot (non-activating) loads must disable the cache to avoid storing a registry + // whose commands were never globally registered. + if (options.activate === false && options.cache !== false) { + throw new Error( + "loadOpenClawPlugins: activate:false requires cache:false to prevent command registry divergence", + ); + } const env = options.env ?? process.env; // Test env: default-disable plugins unless explicitly configured. // This keeps unit/gateway suites fast and avoids loading heavyweight plugin deps by accident. @@ -647,24 +666,37 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const logger = options.logger ?? defaultLogger(); const validateOnly = options.mode === "validate"; const normalized = normalizePluginsConfig(cfg.plugins); + const onlyPluginIds = normalizeScopedPluginIds(options.onlyPluginIds); + const onlyPluginIdSet = onlyPluginIds ? new Set(onlyPluginIds) : null; + const shouldActivate = options.activate !== false; + // NOTE: `activate` is intentionally excluded from the cache key. All non-activating + // (snapshot) callers pass `cache: false` via loadOnboardingPluginRegistry(), so they + // never read from or write to the cache. Including `activate` here would be misleading + // — it would imply mixed-activate caching is supported, when in practice it is not. const cacheKey = buildCacheKey({ workspaceDir: options.workspaceDir, plugins: normalized, installs: cfg.plugins?.installs, env, + onlyPluginIds, }); const cacheEnabled = options.cache !== false; if (cacheEnabled) { const cached = getCachedPluginRegistry(cacheKey); if (cached) { - activatePluginRegistry(cached, cacheKey); + if (shouldActivate) { + activatePluginRegistry(cached, cacheKey); + } return cached; } } - // Clear previously registered plugin commands before reloading - clearPluginCommands(); - clearPluginInteractiveHandlers(); + // Clear previously registered plugin commands before reloading. + // Skip for non-activating (snapshot) loads to avoid wiping commands from other plugins. + if (shouldActivate) { + clearPluginCommands(); + clearPluginInteractiveHandlers(); + } // Lazily initialize the runtime so startup paths that discover/skip plugins do // not eagerly load every channel runtime dependency. @@ -703,6 +735,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi logger, runtime, coreGatewayHandlers: options.coreGatewayHandlers as Record, + suppressGlobalCommands: !shouldActivate, }); const discovery = discoverOpenClawPlugins({ @@ -725,11 +758,15 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi pluginsEnabled: normalized.enabled, allow: normalized.allow, warningCacheKey: cacheKey, - discoverablePlugins: manifestRegistry.plugins.map((plugin) => ({ - id: plugin.id, - source: plugin.source, - origin: plugin.origin, - })), + // Keep warning input scoped as well so partial snapshot loads only mention the + // plugins that were intentionally requested for this registry. + discoverablePlugins: manifestRegistry.plugins + .filter((plugin) => !onlyPluginIdSet || onlyPluginIdSet.has(plugin.id)) + .map((plugin) => ({ + id: plugin.id, + source: plugin.source, + origin: plugin.origin, + })), }); const provenance = buildProvenanceIndex({ config: cfg, @@ -786,6 +823,11 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } const pluginId = manifestRecord.id; + // Filter again at import time as a final guard. The earlier manifest filter keeps + // warnings scoped; this one prevents loading/registering anything outside the scope. + if (onlyPluginIdSet && !onlyPluginIdSet.has(pluginId)) { + continue; + } const existingOrigin = seenIds.get(pluginId); if (existingOrigin) { const record = createPluginRecord({ @@ -1059,7 +1101,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } } - if (typeof memorySlot === "string" && !memorySlotMatched) { + // Scoped snapshot loads may intentionally omit the configured memory plugin, so only + // emit the missing-memory diagnostic for full registry loads. + if (!onlyPluginIdSet && typeof memorySlot === "string" && !memorySlotMatched) { registry.diagnostics.push({ level: "warn", message: `memory slot plugin not found or not marked as memory: ${memorySlot}`, @@ -1076,7 +1120,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi if (cacheEnabled) { setCachedPluginRegistry(cacheKey, registry); } - activatePluginRegistry(registry, cacheKey); + if (shouldActivate) { + activatePluginRegistry(registry, cacheKey); + } return registry; } diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 4b28c277e05..56abbe79bb4 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -10,7 +10,7 @@ import type { import { registerInternalHook } from "../hooks/internal-hooks.js"; import type { HookEntry } from "../hooks/types.js"; import { resolveUserPath } from "../utils.js"; -import { registerPluginCommand } from "./commands.js"; +import { registerPluginCommand, validatePluginCommandDefinition } from "./commands.js"; import { normalizePluginHttpPath } from "./http-path.js"; import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; import { registerPluginInteractiveHandler } from "./interactive.js"; @@ -177,6 +177,9 @@ export type PluginRegistryParams = { logger: PluginLogger; coreGatewayHandlers?: GatewayRequestHandlers; runtime: PluginRuntime; + // When true, skip writing to the global plugin command registry during register(). + // Used by non-activating snapshot loads to avoid leaking commands into the running gateway. + suppressGlobalCommands?: boolean; }; type PluginTypedHookPolicy = { @@ -615,19 +618,37 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { return; } - // Register with the plugin command system (validates name and checks for duplicates) - const result = registerPluginCommand(record.id, command, { - pluginName: record.name, - pluginRoot: record.rootDir, - }); - if (!result.ok) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `command registration failed: ${result.error}`, + // For snapshot (non-activating) loads, record the command locally without touching the + // global plugin command registry so running gateway commands stay intact. + // We still validate the command definition so diagnostics match the real activation path. + // NOTE: cross-plugin duplicate command detection is intentionally skipped here because + // snapshot registries are isolated and never write to the global command table. Conflicts + // will surface when the plugin is loaded via the normal activation path at gateway startup. + if (registryParams.suppressGlobalCommands) { + const validationError = validatePluginCommandDefinition(command); + if (validationError) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `command registration failed: ${validationError}`, + }); + return; + } + } else { + const result = registerPluginCommand(record.id, command, { + pluginName: record.name, + pluginRoot: record.rootDir, }); - return; + if (!result.ok) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `command registration failed: ${result.error}`, + }); + return; + } } record.commands.push(name); From e7555724af1514c874865702307f84f2aa5e273f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 16:57:16 -0700 Subject: [PATCH 051/943] feat(plugins): add provider usage runtime hooks --- src/infra/provider-usage.auth.plugin.test.ts | 34 +++ src/infra/provider-usage.auth.ts | 285 ++++++++++--------- src/infra/provider-usage.load.plugin.test.ts | 64 +++++ src/infra/provider-usage.load.ts | 114 ++++++-- src/plugin-sdk/core.ts | 8 + src/plugin-sdk/google-gemini-cli-auth.ts | 9 +- src/plugin-sdk/index.ts | 8 + src/plugins/provider-runtime.test.ts | 50 ++++ src/plugins/provider-runtime.ts | 22 ++ src/plugins/types.ts | 88 ++++++ 10 files changed, 524 insertions(+), 158 deletions(-) create mode 100644 src/infra/provider-usage.auth.plugin.test.ts create mode 100644 src/infra/provider-usage.load.plugin.test.ts diff --git a/src/infra/provider-usage.auth.plugin.test.ts b/src/infra/provider-usage.auth.plugin.test.ts new file mode 100644 index 00000000000..6782e89489b --- /dev/null +++ b/src/infra/provider-usage.auth.plugin.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const resolveProviderUsageAuthWithPluginMock = vi.fn(); + +vi.mock("../plugins/provider-runtime.js", () => ({ + resolveProviderUsageAuthWithPlugin: (...args: unknown[]) => + resolveProviderUsageAuthWithPluginMock(...args), +})); + +import { resolveProviderAuths } from "./provider-usage.auth.js"; + +describe("resolveProviderAuths plugin seam", () => { + beforeEach(() => { + resolveProviderUsageAuthWithPluginMock.mockReset(); + resolveProviderUsageAuthWithPluginMock.mockResolvedValue(null); + }); + + it("prefers plugin-owned usage auth when available", async () => { + resolveProviderUsageAuthWithPluginMock.mockResolvedValueOnce({ + token: "plugin-zai-token", + }); + + await expect( + resolveProviderAuths({ + providers: ["zai"], + }), + ).resolves.toEqual([ + { + provider: "zai", + token: "plugin-zai-token", + }, + ]); + }); +}); diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index 3c6246d0786..a3981fe5f32 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -11,7 +11,8 @@ import { import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js"; import { resolveUsableCustomProviderApiKey } from "../agents/model-auth.js"; import { normalizeProviderId } from "../agents/model-selection.js"; -import { loadConfig } from "../config/config.js"; +import { loadConfig, type OpenClawConfig } from "../config/config.js"; +import { resolveProviderUsageAuthWithPlugin } from "../plugins/provider-runtime.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; import { resolveRequiredHomeDir } from "./home-dir.js"; import type { UsageProviderId } from "./provider-usage.types.js"; @@ -22,6 +23,22 @@ export type ProviderAuth = { accountId?: string; }; +type AuthStore = ReturnType; + +type UsageAuthState = { + cfg: OpenClawConfig; + store: AuthStore; + env: NodeJS.ProcessEnv; + agentDir?: string; +}; + +const LEGACY_OAUTH_USAGE_PROVIDERS = new Set([ + "anthropic", + "github-copilot", + "google-gemini-cli", + "openai-codex", +]); + function parseGoogleToken(apiKey: string): { token: string } | null { try { const parsed = JSON.parse(apiKey) as { token?: unknown }; @@ -34,36 +51,10 @@ function parseGoogleToken(apiKey: string): { token: string } | null { return null; } -function resolveZaiApiKey(): string | undefined { - const envDirect = - normalizeSecretInput(process.env.ZAI_API_KEY) || normalizeSecretInput(process.env.Z_AI_API_KEY); - if (envDirect) { - return envDirect; - } - - const cfg = loadConfig(); - const key = - resolveUsableCustomProviderApiKey({ cfg, provider: "zai" })?.apiKey ?? - resolveUsableCustomProviderApiKey({ cfg, provider: "z-ai" })?.apiKey; - if (key) { - return key; - } - - const store = ensureAuthProfileStore(); - const apiProfile = [ - ...listProfilesForProvider(store, "zai"), - ...listProfilesForProvider(store, "z-ai"), - ].find((id) => store.profiles[id]?.type === "api_key"); - if (apiProfile) { - const cred = store.profiles[apiProfile]; - if (cred?.type === "api_key" && normalizeSecretInput(cred.key)) { - return normalizeSecretInput(cred.key); - } - } - +function resolveLegacyZaiApiKey(state: UsageAuthState): string | undefined { try { const authPath = path.join( - resolveRequiredHomeDir(process.env, os.homedir), + resolveRequiredHomeDir(state.env, os.homedir), ".pi", "agent", "auth.json", @@ -81,41 +72,32 @@ function resolveZaiApiKey(): string | undefined { } } -function resolveMinimaxApiKey(): string | undefined { - return resolveProviderApiKeyFromConfigAndStore({ - providerId: "minimax", - envDirect: [process.env.MINIMAX_CODE_PLAN_KEY, process.env.MINIMAX_API_KEY], - }); -} - -function resolveXiaomiApiKey(): string | undefined { - return resolveProviderApiKeyFromConfigAndStore({ - providerId: "xiaomi", - envDirect: [process.env.XIAOMI_API_KEY], - }); -} - function resolveProviderApiKeyFromConfigAndStore(params: { - providerId: UsageProviderId; - envDirect: Array; + state: UsageAuthState; + providerIds: string[]; + envDirect?: Array; }): string | undefined { - const envDirect = params.envDirect.map(normalizeSecretInput).find(Boolean); + const envDirect = params.envDirect?.map(normalizeSecretInput).find(Boolean); if (envDirect) { return envDirect; } - const cfg = loadConfig(); - const key = resolveUsableCustomProviderApiKey({ - cfg, - provider: params.providerId, - })?.apiKey; - if (key) { - return key; + for (const providerId of params.providerIds) { + const key = resolveUsableCustomProviderApiKey({ + cfg: params.state.cfg, + provider: providerId, + })?.apiKey; + if (key) { + return key; + } } - const store = ensureAuthProfileStore(); - const cred = listProfilesForProvider(store, params.providerId) - .map((id) => store.profiles[id]) + const normalizedProviderIds = new Set( + params.providerIds.map((providerId) => normalizeProviderId(providerId)).filter(Boolean), + ); + const cred = [...normalizedProviderIds] + .flatMap((providerId) => listProfilesForProvider(params.state.store, providerId)) + .map((id) => params.state.store.profiles[id]) .find( ( profile, @@ -142,22 +124,18 @@ function resolveProviderApiKeyFromConfigAndStore(params: { } async function resolveOAuthToken(params: { + state: UsageAuthState; provider: UsageProviderId; - agentDir?: string; }): Promise { - const cfg = loadConfig(); - const store = ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: false, - }); const order = resolveAuthProfileOrder({ - cfg, - store, + cfg: params.state.cfg, + store: params.state.store, provider: params.provider, }); const deduped = dedupeProfileIds(order); for (const profileId of deduped) { - const cred = store.profiles[profileId]; + const cred = params.state.store.profiles[profileId]; if (!cred || (cred.type !== "oauth" && cred.type !== "token")) { continue; } @@ -166,25 +144,21 @@ async function resolveOAuthToken(params: { // Usage snapshots should work even if config profile metadata is stale. // (e.g. config says api_key but the store has a token profile.) cfg: undefined, - store, + store: params.state.store, profileId, - agentDir: params.agentDir, + agentDir: params.state.agentDir, }); - if (resolved) { - let token = resolved.apiKey; - if (params.provider === "google-gemini-cli") { - const parsed = parseGoogleToken(resolved.apiKey); - token = parsed?.token ?? resolved.apiKey; - } - return { - provider: params.provider, - token, - accountId: - cred.type === "oauth" && "accountId" in cred - ? (cred as { accountId?: string }).accountId - : undefined, - }; + if (!resolved) { + continue; } + return { + provider: params.provider, + token: resolved.apiKey, + accountId: + cred.type === "oauth" && "accountId" in cred + ? (cred as { accountId?: string }).accountId + : undefined, + }; } catch { // ignore } @@ -193,33 +167,47 @@ async function resolveOAuthToken(params: { return null; } -function resolveOAuthProviders(agentDir?: string): UsageProviderId[] { - const store = ensureAuthProfileStore(agentDir, { - allowKeychainPrompt: false, +async function resolveProviderUsageAuthViaPlugin(params: { + state: UsageAuthState; + provider: UsageProviderId; +}): Promise { + const resolved = await resolveProviderUsageAuthWithPlugin({ + provider: params.provider, + config: params.state.cfg, + env: params.state.env, + context: { + config: params.state.cfg, + agentDir: params.state.agentDir, + env: params.state.env, + provider: params.provider, + resolveApiKeyFromConfigAndStore: (options) => + resolveProviderApiKeyFromConfigAndStore({ + state: params.state, + providerIds: options?.providerIds ?? [params.provider], + envDirect: options?.envDirect, + }), + resolveOAuthToken: async () => { + const auth = await resolveOAuthToken({ + state: params.state, + provider: params.provider, + }); + return auth + ? { + token: auth.token, + ...(auth.accountId ? { accountId: auth.accountId } : {}), + } + : null; + }, + }, }); - const cfg = loadConfig(); - const providers = [ - "anthropic", - "github-copilot", - "google-gemini-cli", - "openai-codex", - ] satisfies UsageProviderId[]; - const isOAuthLikeCredential = (id: string) => { - const cred = store.profiles[id]; - return cred?.type === "oauth" || cred?.type === "token"; + if (!resolved?.token) { + return null; + } + return { + provider: params.provider, + token: resolved.token, + ...(resolved.accountId ? { accountId: resolved.accountId } : {}), }; - return providers.filter((provider) => { - const profiles = listProfilesForProvider(store, provider).filter(isOAuthLikeCredential); - if (profiles.length > 0) { - return true; - } - const normalized = normalizeProviderId(provider); - const configuredProfiles = Object.entries(cfg.auth?.profiles ?? {}) - .filter(([, profile]) => normalizeProviderId(profile.provider) === normalized) - .map(([id]) => id) - .filter(isOAuthLikeCredential); - return configuredProfiles.length > 0; - }); } export async function resolveProviderAuths(params: { @@ -231,42 +219,83 @@ export async function resolveProviderAuths(params: { return params.auth; } - const oauthProviders = resolveOAuthProviders(params.agentDir); + const state: UsageAuthState = { + cfg: loadConfig(), + store: ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }), + env: process.env, + agentDir: params.agentDir, + }; const auths: ProviderAuth[] = []; for (const provider of params.providers) { + const pluginAuth = await resolveProviderUsageAuthViaPlugin({ + state, + provider, + }); + if (pluginAuth) { + auths.push(pluginAuth); + continue; + } + if (provider === "zai") { - const apiKey = resolveZaiApiKey(); - if (apiKey) { - auths.push({ provider, token: apiKey }); - } - continue; - } - if (provider === "minimax") { - const apiKey = resolveMinimaxApiKey(); - if (apiKey) { - auths.push({ provider, token: apiKey }); - } - continue; - } - if (provider === "xiaomi") { - const apiKey = resolveXiaomiApiKey(); + const apiKey = + resolveProviderApiKeyFromConfigAndStore({ + state, + providerIds: ["zai", "z-ai"], + envDirect: [state.env.ZAI_API_KEY, state.env.Z_AI_API_KEY], + }) ?? resolveLegacyZaiApiKey(state); if (apiKey) { auths.push({ provider, token: apiKey }); } continue; } - if (!oauthProviders.includes(provider)) { + if (provider === "minimax") { + const apiKey = resolveProviderApiKeyFromConfigAndStore({ + state, + providerIds: ["minimax"], + envDirect: [state.env.MINIMAX_CODE_PLAN_KEY, state.env.MINIMAX_API_KEY], + }); + if (apiKey) { + auths.push({ provider, token: apiKey }); + } continue; } - const auth = await resolveOAuthToken({ - provider, - agentDir: params.agentDir, - }); - if (auth) { - auths.push(auth); + + if (provider === "xiaomi") { + const apiKey = resolveProviderApiKeyFromConfigAndStore({ + state, + providerIds: ["xiaomi"], + envDirect: [state.env.XIAOMI_API_KEY], + }); + if (apiKey) { + auths.push({ provider, token: apiKey }); + } + continue; } + + if (!LEGACY_OAUTH_USAGE_PROVIDERS.has(provider)) { + continue; + } + + const auth = await resolveOAuthToken({ + state, + provider, + }); + if (!auth) { + continue; + } + if (provider === "google-gemini-cli") { + const parsed = parseGoogleToken(auth.token); + auths.push({ + ...auth, + token: parsed?.token ?? auth.token, + }); + continue; + } + auths.push(auth); } return auths; diff --git a/src/infra/provider-usage.load.plugin.test.ts b/src/infra/provider-usage.load.plugin.test.ts new file mode 100644 index 00000000000..cf78ac667da --- /dev/null +++ b/src/infra/provider-usage.load.plugin.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createProviderUsageFetch } from "../test-utils/provider-usage-fetch.js"; + +const resolveProviderUsageSnapshotWithPluginMock = vi.fn(); + +vi.mock("../config/config.js", () => ({ + loadConfig: () => ({}), +})); + +vi.mock("../plugins/provider-runtime.js", () => ({ + resolveProviderUsageSnapshotWithPlugin: (...args: unknown[]) => + resolveProviderUsageSnapshotWithPluginMock(...args), +})); + +import { loadProviderUsageSummary } from "./provider-usage.load.js"; + +const usageNow = Date.UTC(2026, 0, 7, 0, 0, 0); + +describe("provider-usage.load plugin seam", () => { + beforeEach(() => { + resolveProviderUsageSnapshotWithPluginMock.mockReset(); + resolveProviderUsageSnapshotWithPluginMock.mockResolvedValue(null); + }); + + it("prefers plugin-owned usage snapshots before the legacy core switch", async () => { + resolveProviderUsageSnapshotWithPluginMock.mockResolvedValueOnce({ + provider: "github-copilot", + displayName: "Copilot", + windows: [{ label: "Plugin", usedPercent: 11 }], + }); + const mockFetch = createProviderUsageFetch(async () => { + throw new Error("legacy fetch should not run"); + }); + + await expect( + loadProviderUsageSummary({ + now: usageNow, + auth: [{ provider: "github-copilot", token: "copilot-token" }], + fetch: mockFetch as unknown as typeof fetch, + }), + ).resolves.toEqual({ + updatedAt: usageNow, + providers: [ + { + provider: "github-copilot", + displayName: "Copilot", + windows: [{ label: "Plugin", usedPercent: 11 }], + }, + ], + }); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(resolveProviderUsageSnapshotWithPluginMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "github-copilot", + context: expect.objectContaining({ + provider: "github-copilot", + token: "copilot-token", + timeoutMs: 5_000, + }), + }), + ); + }); +}); diff --git a/src/infra/provider-usage.load.ts b/src/infra/provider-usage.load.ts index b62cfec728f..9b50285c64f 100644 --- a/src/infra/provider-usage.load.ts +++ b/src/infra/provider-usage.load.ts @@ -1,3 +1,5 @@ +import { loadConfig, type OpenClawConfig } from "../config/config.js"; +import { resolveProviderUsageSnapshotWithPlugin } from "../plugins/provider-runtime.js"; import { resolveFetch } from "./fetch.js"; import { type ProviderAuth, resolveProviderAuths } from "./provider-usage.auth.js"; import { @@ -27,14 +29,88 @@ type UsageSummaryOptions = { providers?: UsageProviderId[]; auth?: ProviderAuth[]; agentDir?: string; + workspaceDir?: string; + config?: OpenClawConfig; + env?: NodeJS.ProcessEnv; fetch?: typeof fetch; }; +async function fetchProviderUsageSnapshot(params: { + auth: ProviderAuth; + config: OpenClawConfig; + env: NodeJS.ProcessEnv; + agentDir?: string; + workspaceDir?: string; + timeoutMs: number; + fetchFn: typeof fetch; +}): Promise { + const pluginSnapshot = await resolveProviderUsageSnapshotWithPlugin({ + provider: params.auth.provider, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + context: { + config: params.config, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + env: params.env, + provider: params.auth.provider, + token: params.auth.token, + accountId: params.auth.accountId, + timeoutMs: params.timeoutMs, + fetchFn: params.fetchFn, + }, + }); + if (pluginSnapshot) { + return pluginSnapshot; + } + + switch (params.auth.provider) { + case "anthropic": + return await fetchClaudeUsage(params.auth.token, params.timeoutMs, params.fetchFn); + case "github-copilot": + return await fetchCopilotUsage(params.auth.token, params.timeoutMs, params.fetchFn); + case "google-gemini-cli": + return await fetchGeminiUsage( + params.auth.token, + params.timeoutMs, + params.fetchFn, + params.auth.provider, + ); + case "openai-codex": + return await fetchCodexUsage( + params.auth.token, + params.auth.accountId, + params.timeoutMs, + params.fetchFn, + ); + case "minimax": + return await fetchMinimaxUsage(params.auth.token, params.timeoutMs, params.fetchFn); + case "xiaomi": + return { + provider: "xiaomi", + displayName: PROVIDER_LABELS.xiaomi, + windows: [], + }; + case "zai": + return await fetchZaiUsage(params.auth.token, params.timeoutMs, params.fetchFn); + default: + return { + provider: params.auth.provider, + displayName: PROVIDER_LABELS[params.auth.provider], + windows: [], + error: "Unsupported provider", + }; + } +} + export async function loadProviderUsageSummary( opts: UsageSummaryOptions = {}, ): Promise { const now = opts.now ?? Date.now(); const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const config = opts.config ?? loadConfig(); + const env = opts.env ?? process.env; const fetchFn = resolveFetch(opts.fetch); if (!fetchFn) { throw new Error("fetch is not available"); @@ -51,35 +127,15 @@ export async function loadProviderUsageSummary( const tasks = auths.map((auth) => withTimeout( - (async (): Promise => { - switch (auth.provider) { - case "anthropic": - return await fetchClaudeUsage(auth.token, timeoutMs, fetchFn); - case "github-copilot": - return await fetchCopilotUsage(auth.token, timeoutMs, fetchFn); - case "google-gemini-cli": - return await fetchGeminiUsage(auth.token, timeoutMs, fetchFn, auth.provider); - case "openai-codex": - return await fetchCodexUsage(auth.token, auth.accountId, timeoutMs, fetchFn); - case "minimax": - return await fetchMinimaxUsage(auth.token, timeoutMs, fetchFn); - case "xiaomi": - return { - provider: "xiaomi", - displayName: PROVIDER_LABELS.xiaomi, - windows: [], - }; - case "zai": - return await fetchZaiUsage(auth.token, timeoutMs, fetchFn); - default: - return { - provider: auth.provider, - displayName: PROVIDER_LABELS[auth.provider], - windows: [], - error: "Unsupported provider", - }; - } - })(), + fetchProviderUsageSnapshot({ + auth, + config, + env, + agentDir: opts.agentDir, + workspaceDir: opts.workspaceDir, + timeoutMs, + fetchFn, + }), timeoutMs + 1000, { provider: auth.provider, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 82dac5fd88c..d8b94a53545 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -5,10 +5,13 @@ export type { ProviderCatalogContext, ProviderCatalogResult, ProviderCacheTtlEligibilityContext, + ProviderFetchUsageSnapshotContext, ProviderPreparedRuntimeAuth, + ProviderResolvedUsageAuth, ProviderPrepareExtraParamsContext, ProviderPrepareDynamicModelContext, ProviderPrepareRuntimeAuthContext, + ProviderResolveUsageAuthContext, ProviderResolveDynamicModelContext, ProviderNormalizeResolvedModelContext, ProviderRuntimeModel, @@ -22,6 +25,11 @@ export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { GatewayRequestHandlerOptions } from "../gateway/server-methods/types.js"; +export type { + ProviderUsageSnapshot, + UsageProviderId, + UsageWindow, +} from "../infra/provider-usage.types.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; diff --git a/src/plugin-sdk/google-gemini-cli-auth.ts b/src/plugin-sdk/google-gemini-cli-auth.ts index 213f78cfc96..a03002feaab 100644 --- a/src/plugin-sdk/google-gemini-cli-auth.ts +++ b/src/plugin-sdk/google-gemini-cli-auth.ts @@ -4,5 +4,12 @@ export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; export { isWSL2Sync } from "../infra/wsl.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; -export type { OpenClawPluginApi, ProviderAuthContext } from "../plugins/types.js"; +export type { + OpenClawPluginApi, + ProviderAuthContext, + ProviderFetchUsageSnapshotContext, + ProviderResolveDynamicModelContext, + ProviderRuntimeModel, +} from "../plugins/types.js"; +export type { ProviderUsageSnapshot } from "../infra/provider-usage.types.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 36562427e18..dc59602b7c2 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -110,15 +110,23 @@ export type { ProviderAuthContext, ProviderAuthResult, ProviderCacheTtlEligibilityContext, + ProviderFetchUsageSnapshotContext, ProviderPreparedRuntimeAuth, + ProviderResolvedUsageAuth, ProviderPrepareExtraParamsContext, ProviderPrepareDynamicModelContext, ProviderPrepareRuntimeAuthContext, + ProviderResolveUsageAuthContext, ProviderResolveDynamicModelContext, ProviderNormalizeResolvedModelContext, ProviderRuntimeModel, ProviderWrapStreamFnContext, } from "../plugins/types.js"; +export type { + ProviderUsageSnapshot, + UsageProviderId, + UsageWindow, +} from "../infra/provider-usage.types.js"; export type { ConversationRef, SessionBindingBindInput, diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 723c5344bb4..1ca9ef446b6 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -10,7 +10,9 @@ vi.mock("./providers.js", () => ({ import { prepareProviderExtraParams, resolveProviderCacheTtlEligibility, + resolveProviderUsageSnapshotWithPlugin, resolveProviderCapabilitiesWithPlugin, + resolveProviderUsageAuthWithPlugin, normalizeProviderResolvedModelWithPlugin, prepareProviderDynamicModel, prepareProviderRuntimeAuth, @@ -66,6 +68,15 @@ describe("provider-runtime", () => { baseUrl: "https://runtime.example.com/v1", expiresAt: 123, })); + const resolveUsageAuth = vi.fn(async () => ({ + token: "usage-token", + accountId: "usage-account", + })); + const fetchUsageSnapshot = vi.fn(async () => ({ + provider: "zai" as const, + displayName: "Demo", + windows: [{ label: "Day", usedPercent: 25 }], + })); resolvePluginProvidersMock.mockReturnValue([ { id: "demo", @@ -86,6 +97,8 @@ describe("provider-runtime", () => { api: "openai-codex-responses", }), prepareRuntimeAuth, + resolveUsageAuth, + fetchUsageSnapshot, isCacheTtlEligible: ({ modelId }) => modelId.startsWith("anthropic/"), }, ]); @@ -176,6 +189,41 @@ describe("provider-runtime", () => { expiresAt: 123, }); + await expect( + resolveProviderUsageAuthWithPlugin({ + provider: "demo", + env: process.env, + context: { + config: {} as never, + env: process.env, + provider: "demo", + resolveApiKeyFromConfigAndStore: () => "source-token", + resolveOAuthToken: async () => null, + }, + }), + ).resolves.toMatchObject({ + token: "usage-token", + accountId: "usage-account", + }); + + await expect( + resolveProviderUsageSnapshotWithPlugin({ + provider: "demo", + env: process.env, + context: { + config: {} as never, + env: process.env, + provider: "demo", + token: "usage-token", + timeoutMs: 5_000, + fetchFn: vi.fn() as never, + }, + }), + ).resolves.toMatchObject({ + provider: "zai", + windows: [{ label: "Day", usedPercent: 25 }], + }); + expect( resolveProviderCacheTtlEligibility({ provider: "demo", @@ -188,5 +236,7 @@ describe("provider-runtime", () => { expect(prepareDynamicModel).toHaveBeenCalledTimes(1); expect(prepareRuntimeAuth).toHaveBeenCalledTimes(1); + expect(resolveUsageAuth).toHaveBeenCalledTimes(1); + expect(fetchUsageSnapshot).toHaveBeenCalledTimes(1); }); }); diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index a96cc7a0569..7397a52abae 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -3,9 +3,11 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolvePluginProviders } from "./providers.js"; import type { ProviderCacheTtlEligibilityContext, + ProviderFetchUsageSnapshotContext, ProviderPrepareExtraParamsContext, ProviderPrepareDynamicModelContext, ProviderPrepareRuntimeAuthContext, + ProviderResolveUsageAuthContext, ProviderPlugin, ProviderResolveDynamicModelContext, ProviderRuntimeModel, @@ -113,6 +115,26 @@ export async function prepareProviderRuntimeAuth(params: { return await resolveProviderRuntimePlugin(params)?.prepareRuntimeAuth?.(params.context); } +export async function resolveProviderUsageAuthWithPlugin(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderResolveUsageAuthContext; +}) { + return await resolveProviderRuntimePlugin(params)?.resolveUsageAuth?.(params.context); +} + +export async function resolveProviderUsageSnapshotWithPlugin(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderFetchUsageSnapshotContext; +}) { + return await resolveProviderRuntimePlugin(params)?.fetchUsageSnapshot?.(params.context); +} + export function resolveProviderCacheTtlEligibility(params: { provider: string; config?: OpenClawConfig; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 4cb6ef92ee4..6b26dfd8fe6 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -23,6 +23,7 @@ import type { ModelProviderConfig } from "../config/types.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import type { InternalHookHandler } from "../hooks/internal-hooks.js"; import type { HookEntry } from "../hooks/types.js"; +import type { ProviderUsageSnapshot } from "../infra/provider-usage.types.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { PluginRuntime } from "./runtime/types.js"; @@ -288,6 +289,67 @@ export type ProviderPreparedRuntimeAuth = { expiresAt?: number; }; +/** + * Usage/billing auth input for providers that expose quota/usage endpoints. + * + * This hook is intentionally separate from `prepareRuntimeAuth`: usage + * snapshots often need a different credential source than live inference + * requests, and they run outside the embedded runner. + * + * The helper methods cover the common OpenClaw auth resolution paths: + * + * - `resolveApiKeyFromConfigAndStore`: env/config/plain token/api_key profiles + * - `resolveOAuthToken`: oauth/token profiles resolved through the auth store + * + * Plugins can still do extra provider-specific work on top (for example parse a + * token blob, read a legacy credential file, or pick between aliases). + */ +export type ProviderResolveUsageAuthContext = { + config: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + provider: string; + resolveApiKeyFromConfigAndStore: (params?: { + providerIds?: string[]; + envDirect?: Array; + }) => string | undefined; + resolveOAuthToken: () => Promise; +}; + +/** + * Result of `resolveUsageAuth`. + * + * `token` is the credential used for provider usage/billing endpoints. + * `accountId` is optional provider-specific metadata used by some usage APIs. + */ +export type ProviderResolvedUsageAuth = { + token: string; + accountId?: string; +}; + +/** + * Usage/quota snapshot input for providers that own their usage endpoint + * fetch/parsing behavior. + * + * This hook runs after `resolveUsageAuth` succeeds. Core still owns summary + * fan-out, timeout wrapping, filtering, and formatting; the provider plugin + * owns the provider-specific HTTP request + response normalization. + * + * Return `null`/`undefined` to fall back to legacy core fetchers. + */ +export type ProviderFetchUsageSnapshotContext = { + config: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + provider: string; + token: string; + accountId?: string; + timeoutMs: number; + fetchFn: typeof fetch; +}; + /** * Provider-owned extra-param normalization before OpenClaw builds its generic * stream option wrapper. @@ -464,6 +526,32 @@ export type ProviderPlugin = { prepareRuntimeAuth?: ( ctx: ProviderPrepareRuntimeAuthContext, ) => Promise; + /** + * Usage/billing auth resolution hook. + * + * Called by provider-usage surfaces (`/usage`, status snapshots, reporting) + * before OpenClaw falls back to legacy core auth resolution. Use this when a + * provider's usage endpoint needs provider-owned token extraction, blob + * parsing, or alias handling. + */ + resolveUsageAuth?: ( + ctx: ProviderResolveUsageAuthContext, + ) => + | Promise + | ProviderResolvedUsageAuth + | null + | undefined; + /** + * Usage/quota snapshot fetch hook. + * + * Called after `resolveUsageAuth` by `/usage` and related reporting surfaces. + * Use this when the provider's usage endpoint or payload shape is + * provider-specific and you want that logic to live with the provider plugin + * instead of the core switchboard. + */ + fetchUsageSnapshot?: ( + ctx: ProviderFetchUsageSnapshotContext, + ) => Promise | ProviderUsageSnapshot | null | undefined; /** * Provider-owned cache TTL eligibility. * From 8e2a1d0941c7e93e31dfc69a5914f4130bffc50d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 16:57:24 -0700 Subject: [PATCH 052/943] feat(plugins): move bundled providers behind plugin hooks --- extensions/github-copilot/index.ts | 4 + .../google-gemini-cli-auth/index.test.ts | 104 +++++++++++++++ extensions/google-gemini-cli-auth/index.ts | 83 ++++++++++++ extensions/minimax/index.ts | 9 ++ extensions/mistral/index.ts | 33 +++++ extensions/mistral/openclaw.plugin.json | 9 ++ extensions/mistral/package.json | 12 ++ extensions/openai-codex/index.test.ts | 36 ++++++ extensions/openai-codex/index.ts | 4 + extensions/opencode-go/index.ts | 26 ++++ extensions/opencode-go/openclaw.plugin.json | 9 ++ extensions/opencode-go/package.json | 12 ++ extensions/opencode/index.ts | 26 ++++ extensions/opencode/openclaw.plugin.json | 9 ++ extensions/opencode/package.json | 12 ++ extensions/xiaomi/index.ts | 12 ++ extensions/zai/index.test.ts | 112 +++++++++++++++++ extensions/zai/index.ts | 118 ++++++++++++++++++ extensions/zai/openclaw.plugin.json | 9 ++ extensions/zai/package.json | 12 ++ src/agents/pi-embedded-runner/extra-params.ts | 34 +---- .../model.forward-compat.test.ts | 28 ----- src/agents/pi-embedded-runner/model.ts | 30 +++++ .../pi-embedded-runner/zai-stream-wrappers.ts | 29 +++++ src/agents/provider-capabilities.ts | 15 ++- src/plugins/config-state.ts | 4 + src/plugins/providers.ts | 4 + 27 files changed, 728 insertions(+), 67 deletions(-) create mode 100644 extensions/google-gemini-cli-auth/index.test.ts create mode 100644 extensions/mistral/index.ts create mode 100644 extensions/mistral/openclaw.plugin.json create mode 100644 extensions/mistral/package.json create mode 100644 extensions/opencode-go/index.ts create mode 100644 extensions/opencode-go/openclaw.plugin.json create mode 100644 extensions/opencode-go/package.json create mode 100644 extensions/opencode/index.ts create mode 100644 extensions/opencode/openclaw.plugin.json create mode 100644 extensions/opencode/package.json create mode 100644 extensions/zai/index.test.ts create mode 100644 extensions/zai/index.ts create mode 100644 extensions/zai/openclaw.plugin.json create mode 100644 extensions/zai/package.json create mode 100644 src/agents/pi-embedded-runner/zai-stream-wrappers.ts diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index d38e7442d75..19114472830 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -8,6 +8,7 @@ import { listProfilesForProvider } from "../../src/agents/auth-profiles/profiles import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js"; import { normalizeModelCompat } from "../../src/agents/model-compat.js"; import { coerceSecretRef } from "../../src/config/types.secrets.js"; +import { fetchCopilotUsage } from "../../src/infra/provider-usage.fetch.js"; import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken, @@ -130,6 +131,9 @@ const githubCopilotPlugin = { expiresAt: token.expiresAt, }; }, + resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(), + fetchUsageSnapshot: async (ctx) => + await fetchCopilotUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), }); }, }; diff --git a/extensions/google-gemini-cli-auth/index.test.ts b/extensions/google-gemini-cli-auth/index.test.ts new file mode 100644 index 00000000000..d0542e3473c --- /dev/null +++ b/extensions/google-gemini-cli-auth/index.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import type { ProviderPlugin } from "../../src/plugins/types.js"; +import { + createProviderUsageFetch, + makeResponse, +} from "../../src/test-utils/provider-usage-fetch.js"; +import geminiCliPlugin from "./index.js"; + +function registerProvider(): ProviderPlugin { + let provider: ProviderPlugin | undefined; + geminiCliPlugin.register({ + registerProvider(nextProvider: ProviderPlugin) { + provider = nextProvider; + }, + } as never); + if (!provider) { + throw new Error("provider registration missing"); + } + return provider; +} + +describe("google-gemini-cli-auth plugin", () => { + it("owns gemini 3.1 forward-compat resolution", () => { + const provider = registerProvider(); + const model = provider.resolveDynamicModel?.({ + provider: "google-gemini-cli", + modelId: "gemini-3.1-pro-preview", + modelRegistry: { + find: (_provider: string, id: string) => + id === "gemini-3-pro-preview" + ? { + id, + name: id, + api: "google-gemini-cli", + provider: "google-gemini-cli", + baseUrl: "https://cloudcode-pa.googleapis.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_048_576, + maxTokens: 65_536, + } + : null, + } as never, + }); + + expect(model).toMatchObject({ + id: "gemini-3.1-pro-preview", + provider: "google-gemini-cli", + reasoning: true, + }); + }); + + it("owns usage-token parsing", async () => { + const provider = registerProvider(); + await expect( + provider.resolveUsageAuth?.({ + config: {} as never, + env: {} as NodeJS.ProcessEnv, + provider: "google-gemini-cli", + resolveApiKeyFromConfigAndStore: () => undefined, + resolveOAuthToken: async () => ({ + token: '{"token":"google-oauth-token"}', + accountId: "google-account", + }), + }), + ).resolves.toEqual({ + token: "google-oauth-token", + accountId: "google-account", + }); + }); + + it("owns usage snapshot fetching", async () => { + const provider = registerProvider(); + const mockFetch = createProviderUsageFetch(async (url) => { + if (url.includes("cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota")) { + return makeResponse(200, { + buckets: [ + { modelId: "gemini-3.1-pro-preview", remainingFraction: 0.4 }, + { modelId: "gemini-3.1-flash-preview", remainingFraction: 0.8 }, + ], + }); + } + return makeResponse(404, "not found"); + }); + + const snapshot = await provider.fetchUsageSnapshot?.({ + config: {} as never, + env: {} as NodeJS.ProcessEnv, + provider: "google-gemini-cli", + token: "google-oauth-token", + timeoutMs: 5_000, + fetchFn: mockFetch as unknown as typeof fetch, + }); + + expect(snapshot).toMatchObject({ + provider: "google-gemini-cli", + displayName: "Gemini", + }); + expect(snapshot?.windows[0]).toEqual({ label: "Pro", usedPercent: 60 }); + expect(snapshot?.windows[1]?.label).toBe("Flash"); + expect(snapshot?.windows[1]?.usedPercent).toBeCloseTo(20); + }); +}); diff --git a/extensions/google-gemini-cli-auth/index.ts b/extensions/google-gemini-cli-auth/index.ts index dd84e93ba4e..290cc19598f 100644 --- a/extensions/google-gemini-cli-auth/index.ts +++ b/extensions/google-gemini-cli-auth/index.ts @@ -1,14 +1,23 @@ import { buildOauthProviderAuthResult, emptyPluginConfigSchema, + type ProviderFetchUsageSnapshotContext, type OpenClawPluginApi, type ProviderAuthContext, + type ProviderResolveDynamicModelContext, + type ProviderRuntimeModel, } from "openclaw/plugin-sdk/google-gemini-cli-auth"; +import { normalizeModelCompat } from "../../src/agents/model-compat.js"; +import { fetchGeminiUsage } from "../../src/infra/provider-usage.fetch.js"; import { loginGeminiCliOAuth } from "./oauth.js"; const PROVIDER_ID = "google-gemini-cli"; const PROVIDER_LABEL = "Gemini CLI OAuth"; const DEFAULT_MODEL = "google-gemini-cli/gemini-3.1-pro-preview"; +const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro"; +const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash"; +const GEMINI_3_1_PRO_TEMPLATE_IDS = ["gemini-3-pro-preview"] as const; +const GEMINI_3_1_FLASH_TEMPLATE_IDS = ["gemini-3-flash-preview"] as const; const ENV_VARS = [ "OPENCLAW_GEMINI_OAUTH_CLIENT_ID", "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", @@ -16,6 +25,68 @@ const ENV_VARS = [ "GEMINI_CLI_OAUTH_CLIENT_SECRET", ]; +function cloneFirstTemplateModel(params: { + modelId: string; + templateIds: readonly string[]; + ctx: ProviderResolveDynamicModelContext; +}): ProviderRuntimeModel | undefined { + const trimmedModelId = params.modelId.trim(); + for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { + const template = params.ctx.modelRegistry.find( + PROVIDER_ID, + templateId, + ) as ProviderRuntimeModel | null; + if (!template) { + continue; + } + return normalizeModelCompat({ + ...template, + id: trimmedModelId, + name: trimmedModelId, + reasoning: true, + } as ProviderRuntimeModel); + } + return undefined; +} + +function parseGoogleUsageToken(apiKey: string): string { + try { + const parsed = JSON.parse(apiKey) as { token?: unknown }; + if (typeof parsed?.token === "string") { + return parsed.token; + } + } catch { + // ignore + } + return apiKey; +} + +async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) { + return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID); +} + +function resolveGeminiCliForwardCompatModel( + ctx: ProviderResolveDynamicModelContext, +): ProviderRuntimeModel | undefined { + const trimmed = ctx.modelId.trim(); + const lower = trimmed.toLowerCase(); + + let templateIds: readonly string[]; + if (lower.startsWith(GEMINI_3_1_PRO_PREFIX)) { + templateIds = GEMINI_3_1_PRO_TEMPLATE_IDS; + } else if (lower.startsWith(GEMINI_3_1_FLASH_PREFIX)) { + templateIds = GEMINI_3_1_FLASH_TEMPLATE_IDS; + } else { + return undefined; + } + + return cloneFirstTemplateModel({ + modelId: trimmed, + templateIds, + ctx, + }); +} + const geminiCliPlugin = { id: "google-gemini-cli-auth", name: "Google Gemini CLI Auth", @@ -68,6 +139,18 @@ const geminiCliPlugin = { }, }, ], + resolveDynamicModel: (ctx) => resolveGeminiCliForwardCompatModel(ctx), + resolveUsageAuth: async (ctx) => { + const auth = await ctx.resolveOAuthToken(); + if (!auth) { + return null; + } + return { + ...auth, + token: parseGoogleUsageToken(auth.token), + }; + }, + fetchUsageSnapshot: async (ctx) => await fetchGeminiCliUsage(ctx), }); }, }; diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index 4076362404f..6585e27d7cf 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -1,5 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { buildMinimaxProvider } from "../../src/agents/models-config.providers.static.js"; +import { fetchMinimaxUsage } from "../../src/infra/provider-usage.fetch.js"; const PROVIDER_ID = "minimax"; @@ -30,6 +31,14 @@ const minimaxPlugin = { }; }, }, + resolveUsageAuth: async (ctx) => { + const apiKey = ctx.resolveApiKeyFromConfigAndStore({ + envDirect: [ctx.env.MINIMAX_CODE_PLAN_KEY, ctx.env.MINIMAX_API_KEY], + }); + return apiKey ? { token: apiKey } : null; + }, + fetchUsageSnapshot: async (ctx) => + await fetchMinimaxUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), }); }, }; diff --git a/extensions/mistral/index.ts b/extensions/mistral/index.ts new file mode 100644 index 00000000000..355c957282b --- /dev/null +++ b/extensions/mistral/index.ts @@ -0,0 +1,33 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; + +const PROVIDER_ID = "mistral"; + +const mistralPlugin = { + id: PROVIDER_ID, + name: "Mistral Provider", + description: "Bundled Mistral provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Mistral", + docsPath: "/providers/models", + envVars: ["MISTRAL_API_KEY"], + auth: [], + capabilities: { + transcriptToolCallIdMode: "strict9", + transcriptToolCallIdModelHints: [ + "mistral", + "mixtral", + "codestral", + "pixtral", + "devstral", + "ministral", + "mistralai", + ], + }, + }); + }, +}; + +export default mistralPlugin; diff --git a/extensions/mistral/openclaw.plugin.json b/extensions/mistral/openclaw.plugin.json new file mode 100644 index 00000000000..dd38282811b --- /dev/null +++ b/extensions/mistral/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "mistral", + "providers": ["mistral"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/mistral/package.json b/extensions/mistral/package.json new file mode 100644 index 00000000000..29649db38f5 --- /dev/null +++ b/extensions/mistral/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/mistral-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Mistral provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/openai-codex/index.test.ts b/extensions/openai-codex/index.test.ts index 95dd1aa1a73..53bbd700f17 100644 --- a/extensions/openai-codex/index.test.ts +++ b/extensions/openai-codex/index.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; import type { ProviderPlugin } from "../../src/plugins/types.js"; +import { + createProviderUsageFetch, + makeResponse, +} from "../../src/test-utils/provider-usage-fetch.js"; import openAICodexPlugin from "./index.js"; function registerProvider(): ProviderPlugin { @@ -62,4 +66,36 @@ describe("openai-codex plugin", () => { transport: "auto", }); }); + + it("owns usage snapshot fetching", async () => { + const provider = registerProvider(); + const mockFetch = createProviderUsageFetch(async (url) => { + if (url.includes("chatgpt.com/backend-api/wham/usage")) { + return makeResponse(200, { + rate_limit: { + primary_window: { used_percent: 12, limit_window_seconds: 10800, reset_at: 1_705_000 }, + }, + plan_type: "Plus", + }); + } + return makeResponse(404, "not found"); + }); + + await expect( + provider.fetchUsageSnapshot?.({ + config: {} as never, + env: {} as NodeJS.ProcessEnv, + provider: "openai-codex", + token: "codex-token", + accountId: "acc-1", + timeoutMs: 5_000, + fetchFn: mockFetch as unknown as typeof fetch, + }), + ).resolves.toEqual({ + provider: "openai-codex", + displayName: "Codex", + windows: [{ label: "3h", usedPercent: 12, resetAt: 1_705_000_000 }], + plan: "Plus", + }); + }); }); diff --git a/extensions/openai-codex/index.ts b/extensions/openai-codex/index.ts index 592223f2419..9d8ee0769af 100644 --- a/extensions/openai-codex/index.ts +++ b/extensions/openai-codex/index.ts @@ -10,6 +10,7 @@ import { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.js"; import { normalizeModelCompat } from "../../src/agents/model-compat.js"; import { normalizeProviderId } from "../../src/agents/model-selection.js"; import { buildOpenAICodexProvider } from "../../src/agents/models-config.providers.static.js"; +import { fetchCodexUsage } from "../../src/infra/provider-usage.fetch.js"; const PROVIDER_ID = "openai-codex"; const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; @@ -182,6 +183,9 @@ const openAICodexPlugin = { } return normalizeCodexTransport(ctx.model); }, + resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(), + fetchUsageSnapshot: async (ctx) => + await fetchCodexUsage(ctx.token, ctx.accountId, ctx.timeoutMs, ctx.fetchFn), }); }, }; diff --git a/extensions/opencode-go/index.ts b/extensions/opencode-go/index.ts new file mode 100644 index 00000000000..3740c0190c4 --- /dev/null +++ b/extensions/opencode-go/index.ts @@ -0,0 +1,26 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; + +const PROVIDER_ID = "opencode-go"; + +const opencodeGoPlugin = { + id: PROVIDER_ID, + name: "OpenCode Go Provider", + description: "Bundled OpenCode Go provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "OpenCode Go", + docsPath: "/providers/models", + envVars: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], + auth: [], + capabilities: { + openAiCompatTurnValidation: false, + geminiThoughtSignatureSanitization: true, + geminiThoughtSignatureModelHints: ["gemini"], + }, + }); + }, +}; + +export default opencodeGoPlugin; diff --git a/extensions/opencode-go/openclaw.plugin.json b/extensions/opencode-go/openclaw.plugin.json new file mode 100644 index 00000000000..09d48bcf314 --- /dev/null +++ b/extensions/opencode-go/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "opencode-go", + "providers": ["opencode-go"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/opencode-go/package.json b/extensions/opencode-go/package.json new file mode 100644 index 00000000000..ab32e55d7dc --- /dev/null +++ b/extensions/opencode-go/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/opencode-go-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw OpenCode Go provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/opencode/index.ts b/extensions/opencode/index.ts new file mode 100644 index 00000000000..81175fc5613 --- /dev/null +++ b/extensions/opencode/index.ts @@ -0,0 +1,26 @@ +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; + +const PROVIDER_ID = "opencode"; + +const opencodePlugin = { + id: PROVIDER_ID, + name: "OpenCode Zen Provider", + description: "Bundled OpenCode Zen provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "OpenCode Zen", + docsPath: "/providers/models", + envVars: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], + auth: [], + capabilities: { + openAiCompatTurnValidation: false, + geminiThoughtSignatureSanitization: true, + geminiThoughtSignatureModelHints: ["gemini"], + }, + }); + }, +}; + +export default opencodePlugin; diff --git a/extensions/opencode/openclaw.plugin.json b/extensions/opencode/openclaw.plugin.json new file mode 100644 index 00000000000..f61e9b99b67 --- /dev/null +++ b/extensions/opencode/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "opencode", + "providers": ["opencode"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/opencode/package.json b/extensions/opencode/package.json new file mode 100644 index 00000000000..a8c185cd94b --- /dev/null +++ b/extensions/opencode/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/opencode-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw OpenCode Zen provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/xiaomi/index.ts b/extensions/xiaomi/index.ts index 847d7836ecc..37d7d799691 100644 --- a/extensions/xiaomi/index.ts +++ b/extensions/xiaomi/index.ts @@ -1,5 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { buildXiaomiProvider } from "../../src/agents/models-config.providers.static.js"; +import { PROVIDER_LABELS } from "../../src/infra/provider-usage.shared.js"; const PROVIDER_ID = "xiaomi"; @@ -30,6 +31,17 @@ const xiaomiPlugin = { }; }, }, + resolveUsageAuth: async (ctx) => { + const apiKey = ctx.resolveApiKeyFromConfigAndStore({ + envDirect: [ctx.env.XIAOMI_API_KEY], + }); + return apiKey ? { token: apiKey } : null; + }, + fetchUsageSnapshot: async () => ({ + provider: "xiaomi", + displayName: PROVIDER_LABELS.xiaomi, + windows: [], + }), }); }, }; diff --git a/extensions/zai/index.test.ts b/extensions/zai/index.test.ts new file mode 100644 index 00000000000..119309d31a3 --- /dev/null +++ b/extensions/zai/index.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import type { ProviderPlugin } from "../../src/plugins/types.js"; +import { + createProviderUsageFetch, + makeResponse, +} from "../../src/test-utils/provider-usage-fetch.js"; +import zaiPlugin from "./index.js"; + +function registerProvider(): ProviderPlugin { + let provider: ProviderPlugin | undefined; + zaiPlugin.register({ + registerProvider(nextProvider: ProviderPlugin) { + provider = nextProvider; + }, + } as never); + if (!provider) { + throw new Error("provider registration missing"); + } + return provider; +} + +describe("zai plugin", () => { + it("owns glm-5 forward-compat resolution", () => { + const provider = registerProvider(); + const model = provider.resolveDynamicModel?.({ + provider: "zai", + modelId: "glm-5", + modelRegistry: { + find: (_provider: string, id: string) => + id === "glm-4.7" + ? { + id, + name: id, + api: "openai-completions", + provider: "zai", + baseUrl: "https://api.z.ai/api/paas/v4", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 202_752, + maxTokens: 16_384, + } + : null, + } as never, + }); + + expect(model).toMatchObject({ + id: "glm-5", + provider: "zai", + api: "openai-completions", + reasoning: true, + }); + }); + + it("owns usage auth resolution", async () => { + const provider = registerProvider(); + await expect( + provider.resolveUsageAuth?.({ + config: {} as never, + env: { + ZAI_API_KEY: "env-zai-token", + } as NodeJS.ProcessEnv, + provider: "zai", + resolveApiKeyFromConfigAndStore: () => "env-zai-token", + resolveOAuthToken: async () => null, + }), + ).resolves.toEqual({ + token: "env-zai-token", + }); + }); + + it("owns usage snapshot fetching", async () => { + const provider = registerProvider(); + const mockFetch = createProviderUsageFetch(async (url) => { + if (url.includes("api.z.ai/api/monitor/usage/quota/limit")) { + return makeResponse(200, { + success: true, + code: 200, + data: { + planName: "Pro", + limits: [ + { + type: "TOKENS_LIMIT", + percentage: 25, + unit: 3, + number: 6, + nextResetTime: "2026-01-07T06:00:00Z", + }, + ], + }, + }); + } + return makeResponse(404, "not found"); + }); + + await expect( + provider.fetchUsageSnapshot?.({ + config: {} as never, + env: {} as NodeJS.ProcessEnv, + provider: "zai", + token: "env-zai-token", + timeoutMs: 5_000, + fetchFn: mockFetch as unknown as typeof fetch, + }), + ).resolves.toEqual({ + provider: "zai", + displayName: "z.ai", + windows: [{ label: "Tokens (6h)", usedPercent: 25, resetAt: 1_767_765_600_000 }], + plan: "Pro", + }); + }); +}); diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts new file mode 100644 index 00000000000..d9b81b87dda --- /dev/null +++ b/extensions/zai/index.ts @@ -0,0 +1,118 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + emptyPluginConfigSchema, + type OpenClawPluginApi, + type ProviderResolveDynamicModelContext, + type ProviderRuntimeModel, +} from "openclaw/plugin-sdk/core"; +import { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.js"; +import { normalizeModelCompat } from "../../src/agents/model-compat.js"; +import { createZaiToolStreamWrapper } from "../../src/agents/pi-embedded-runner/zai-stream-wrappers.js"; +import { resolveRequiredHomeDir } from "../../src/infra/home-dir.js"; +import { fetchZaiUsage } from "../../src/infra/provider-usage.fetch.js"; + +const PROVIDER_ID = "zai"; +const GLM5_MODEL_ID = "glm-5"; +const GLM5_TEMPLATE_MODEL_ID = "glm-4.7"; + +function resolveGlm5ForwardCompatModel( + ctx: ProviderResolveDynamicModelContext, +): ProviderRuntimeModel | undefined { + const trimmedModelId = ctx.modelId.trim(); + const lower = trimmedModelId.toLowerCase(); + if (lower !== GLM5_MODEL_ID && !lower.startsWith(`${GLM5_MODEL_ID}-`)) { + return undefined; + } + + const template = ctx.modelRegistry.find( + PROVIDER_ID, + GLM5_TEMPLATE_MODEL_ID, + ) as ProviderRuntimeModel | null; + if (template) { + return normalizeModelCompat({ + ...template, + id: trimmedModelId, + name: trimmedModelId, + reasoning: true, + } as ProviderRuntimeModel); + } + + return normalizeModelCompat({ + id: trimmedModelId, + name: trimmedModelId, + api: "openai-completions", + provider: PROVIDER_ID, + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: DEFAULT_CONTEXT_TOKENS, + maxTokens: DEFAULT_CONTEXT_TOKENS, + } 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; + } +} + +const zaiPlugin = { + id: PROVIDER_ID, + name: "Z.AI Provider", + description: "Bundled Z.AI provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Z.AI", + aliases: ["z-ai", "z.ai"], + docsPath: "/providers/models", + envVars: ["ZAI_API_KEY", "Z_AI_API_KEY"], + auth: [], + resolveDynamicModel: (ctx) => resolveGlm5ForwardCompatModel(ctx), + prepareExtraParams: (ctx) => { + if (ctx.extraParams?.tool_stream !== undefined) { + return ctx.extraParams; + } + return { + ...ctx.extraParams, + tool_stream: true, + }; + }, + wrapStreamFn: (ctx) => + createZaiToolStreamWrapper(ctx.streamFn, ctx.extraParams?.tool_stream !== false), + resolveUsageAuth: async (ctx) => { + const apiKey = ctx.resolveApiKeyFromConfigAndStore({ + providerIds: [PROVIDER_ID, "z-ai"], + envDirect: [ctx.env.ZAI_API_KEY, ctx.env.Z_AI_API_KEY], + }); + if (apiKey) { + return { token: apiKey }; + } + const legacyToken = resolveLegacyZaiUsageToken(ctx.env); + return legacyToken ? { token: legacyToken } : null; + }, + fetchUsageSnapshot: async (ctx) => await fetchZaiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), + isCacheTtlEligible: () => true, + }); + }, +}; + +export default zaiPlugin; diff --git a/extensions/zai/openclaw.plugin.json b/extensions/zai/openclaw.plugin.json new file mode 100644 index 00000000000..5e23160ddb6 --- /dev/null +++ b/extensions/zai/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "zai", + "providers": ["zai"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/zai/package.json b/extensions/zai/package.json new file mode 100644 index 00000000000..10283bbdbdd --- /dev/null +++ b/extensions/zai/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/zai-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Z.AI provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 7f329302803..713b193d7e7 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -33,6 +33,7 @@ import { resolveOpenAIFastMode, resolveOpenAIServiceTier, } from "./openai-stream-wrappers.js"; +import { createZaiToolStreamWrapper } from "./zai-stream-wrappers.js"; /** * Resolve provider-specific extra params from model config. @@ -214,39 +215,6 @@ function createGoogleThinkingPayloadWrapper( }; } -/** - * Create a streamFn wrapper that injects tool_stream=true for Z.AI providers. - * - * Z.AI's API supports the `tool_stream` parameter to enable real-time streaming - * of tool call arguments and reasoning content. When enabled, the API returns - * progressive tool_call deltas, allowing users to see tool execution in real-time. - * - * @see https://docs.z.ai/api-reference#streaming - */ -function createZaiToolStreamWrapper( - baseStreamFn: StreamFn | undefined, - enabled: boolean, -): StreamFn { - const underlying = baseStreamFn ?? streamSimple; - return (model, context, options) => { - if (!enabled) { - return underlying(model, context, options); - } - - const originalOnPayload = options?.onPayload; - return underlying(model, context, { - ...options, - onPayload: (payload) => { - if (payload && typeof payload === "object") { - // Inject tool_stream: true for Z.AI API - (payload as Record).tool_stream = true; - } - return originalOnPayload?.(payload, model); - }, - }); - }; -} - function resolveAliasedParamValue( sources: Array | undefined>, snakeCaseKey: string, diff --git a/src/agents/pi-embedded-runner/model.forward-compat.test.ts b/src/agents/pi-embedded-runner/model.forward-compat.test.ts index 5def8359c13..f0cdc3e29cb 100644 --- a/src/agents/pi-embedded-runner/model.forward-compat.test.ts +++ b/src/agents/pi-embedded-runner/model.forward-compat.test.ts @@ -7,14 +7,12 @@ vi.mock("../pi-model-discovery.js", () => ({ import { buildInlineProviderModels, resolveModel } from "./model.js"; import { - buildOpenAICodexForwardCompatExpectation, GOOGLE_GEMINI_CLI_FLASH_TEMPLATE_MODEL, GOOGLE_GEMINI_CLI_PRO_TEMPLATE_MODEL, makeModel, mockDiscoveredModel, mockGoogleGeminiCliFlashTemplateModel, mockGoogleGeminiCliProTemplateModel, - mockOpenAICodexTemplateModel, resetMockDiscoverModels, } from "./model.test-harness.js"; @@ -42,32 +40,6 @@ describe("pi embedded model e2e smoke", () => { ]); }); - it("builds an openai-codex forward-compat fallback for gpt-5.3-codex", () => { - mockOpenAICodexTemplateModel(); - - const result = resolveModel("openai-codex", "gpt-5.3-codex", "/tmp/agent"); - expect(result.error).toBeUndefined(); - expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.3-codex")); - }); - - it("builds an openai-codex forward-compat fallback for gpt-5.4", () => { - mockOpenAICodexTemplateModel(); - - const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent"); - expect(result.error).toBeUndefined(); - expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.4")); - }); - - it("builds an openai-codex forward-compat fallback for gpt-5.3-codex-spark", () => { - mockOpenAICodexTemplateModel(); - - const result = resolveModel("openai-codex", "gpt-5.3-codex-spark", "/tmp/agent"); - expect(result.error).toBeUndefined(); - expect(result.model).toMatchObject( - buildOpenAICodexForwardCompatExpectation("gpt-5.3-codex-spark"), - ); - }); - it("keeps unknown-model errors for non-forward-compat IDs", () => { const result = resolveModel("openai-codex", "gpt-4.1-mini", "/tmp/agent"); expect(result.model).toBeUndefined(); diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 1a36178f9ce..ed6356a361f 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -34,6 +34,8 @@ type InlineProviderConfig = { headers?: unknown; }; +const PLUGIN_FIRST_DYNAMIC_PROVIDERS = new Set(["google-gemini-cli", "zai"]); + function sanitizeModelHeaders( headers: unknown, opts?: { stripSecretRefMarkers?: boolean }, @@ -230,6 +232,34 @@ function resolveExplicitModelWithRegistry(params: { }; } + if (PLUGIN_FIRST_DYNAMIC_PROVIDERS.has(normalizeProviderId(provider))) { + // Give migrated provider plugins first shot at ids that still keep a core + // forward-compat fallback for disabled-plugin/test compatibility. + const pluginDynamicModel = runProviderDynamicModel({ + provider, + config: cfg, + context: { + config: cfg, + agentDir, + provider, + modelId, + modelRegistry, + providerConfig, + }, + }); + if (pluginDynamicModel) { + return { + kind: "resolved", + model: normalizeResolvedModel({ + provider, + cfg, + agentDir, + model: pluginDynamicModel, + }), + }; + } + } + // Forward-compat fallbacks must be checked BEFORE the generic providerCfg fallback. // Otherwise, configured providers can default to a generic API and break specific transports. const forwardCompat = resolveForwardCompatModel(provider, modelId, modelRegistry); diff --git a/src/agents/pi-embedded-runner/zai-stream-wrappers.ts b/src/agents/pi-embedded-runner/zai-stream-wrappers.ts new file mode 100644 index 00000000000..e6c1077cf5e --- /dev/null +++ b/src/agents/pi-embedded-runner/zai-stream-wrappers.ts @@ -0,0 +1,29 @@ +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import { streamSimple } from "@mariozechner/pi-ai"; + +/** + * Inject `tool_stream=true` for Z.AI requests so tool-call deltas stream in + * real time. Providers can disable this by setting `params.tool_stream=false`. + */ +export function createZaiToolStreamWrapper( + baseStreamFn: StreamFn | undefined, + enabled: boolean, +): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + return (model, context, options) => { + if (!enabled) { + return underlying(model, context, options); + } + + const originalOnPayload = options?.onPayload; + return underlying(model, context, { + ...options, + onPayload: (payload) => { + if (payload && typeof payload === "object") { + (payload as Record).tool_stream = true; + } + return originalOnPayload?.(payload, model); + }, + }); + }; +} diff --git a/src/agents/provider-capabilities.ts b/src/agents/provider-capabilities.ts index 00a09b2386c..6f6f9fe4c9f 100644 --- a/src/agents/provider-capabilities.ts +++ b/src/agents/provider-capabilities.ts @@ -27,7 +27,7 @@ const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = { dropThinkingBlockModelHints: [], }; -const PROVIDER_CAPABILITIES: Record> = { +const CORE_PROVIDER_CAPABILITIES: Record> = { anthropic: { providerFamily: "anthropic", dropThinkingBlockModelHints: ["claude"], @@ -36,6 +36,12 @@ const PROVIDER_CAPABILITIES: Record> = { providerFamily: "anthropic", dropThinkingBlockModelHints: ["claude"], }, + openai: { + providerFamily: "openai", + }, +}; + +const PLUGIN_CAPABILITIES_FALLBACKS: Record> = { mistral: { transcriptToolCallIdMode: "strict9", transcriptToolCallIdModelHints: [ @@ -48,9 +54,6 @@ const PROVIDER_CAPABILITIES: Record> = { "mistralai", ], }, - openai: { - providerFamily: "openai", - }, opencode: { openAiCompatTurnValidation: false, geminiThoughtSignatureSanitization: true, @@ -70,8 +73,8 @@ export function resolveProviderCapabilities(provider?: string | null): ProviderC : undefined; return { ...DEFAULT_PROVIDER_CAPABILITIES, - ...PROVIDER_CAPABILITIES[normalized], - ...pluginCapabilities, + ...CORE_PROVIDER_CAPABILITIES[normalized], + ...(pluginCapabilities ?? PLUGIN_CAPABILITIES_FALLBACKS[normalized]), }; } diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 16345b1b986..33fd5d87b3d 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -33,11 +33,14 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ "kimi-coding", "minimax", "minimax-portal-auth", + "mistral", "modelstudio", "moonshot", "nvidia", "ollama", "openai-codex", + "opencode", + "opencode-go", "openrouter", "phone-control", "qianfan", @@ -51,6 +54,7 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ "vllm", "volcengine", "xiaomi", + "zai", ]); const normalizeList = (value: unknown): string[] => { diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index dda000e2641..fdcd0bb67a9 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -15,11 +15,14 @@ const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "kimi-coding", "minimax", "minimax-portal-auth", + "mistral", "modelstudio", "moonshot", "nvidia", "ollama", "openai-codex", + "opencode", + "opencode-go", "openrouter", "qianfan", "qwen-portal-auth", @@ -31,6 +34,7 @@ const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "volcengine", "vllm", "xiaomi", + "zai", ] as const; function withBundledProviderAllowlistCompat( From c05cfccc176779cccb6783db03c46d66c0c53b15 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 16:57:32 -0700 Subject: [PATCH 053/943] docs(plugins): document provider runtime usage hooks --- docs/concepts/model-providers.md | 23 ++++++++---- docs/tools/plugin.md | 61 +++++++++++++++++++++++++------- 2 files changed, 65 insertions(+), 19 deletions(-) diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index a56b8f76284..7a5ef04ab11 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -22,7 +22,8 @@ For model selection rules, see [/concepts/models](/concepts/models). - Provider plugins can also own provider runtime behavior via `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, - `isCacheTtlEligible`, and `prepareRuntimeAuth`. + `isCacheTtlEligible`, `prepareRuntimeAuth`, `resolveUsageAuth`, and + `fetchUsageSnapshot`. ## Plugin-owned provider behavior @@ -43,22 +44,32 @@ Typical split: - `isCacheTtlEligible`: provider decides which upstream model ids support prompt-cache TTL - `prepareRuntimeAuth`: provider turns a configured credential into a short lived runtime token +- `resolveUsageAuth`: provider resolves usage/quota credentials for `/usage` + and related status/reporting surfaces +- `fetchUsageSnapshot`: provider owns the usage endpoint fetch/parsing while + core still owns the summary shell and formatting Current bundled examples: - `openrouter`: pass-through model ids, request wrappers, provider capability hints, and cache-TTL policy - `github-copilot`: forward-compat model fallback, Claude-thinking transcript - hints, and runtime token exchange + hints, runtime token exchange, and usage endpoint fetching - `openai-codex`: forward-compat model fallback, transport normalization, and - default transport params + default transport params plus usage endpoint fetching +- `google-gemini-cli`: Gemini 3.1 forward-compat fallback plus usage-token + parsing and quota endpoint fetching for usage surfaces - `moonshot`: shared transport, plugin-owned thinking payload normalization - `kilocode`: shared transport, plugin-owned request headers, reasoning payload normalization, Gemini transcript hints, and cache-TTL policy +- `zai`: GLM-5 forward-compat fallback, `tool_stream` defaults, cache-TTL + policy, and usage auth + quota fetching +- `mistral`, `opencode`, and `opencode-go`: plugin-owned capability metadata - `byteplus`, `cloudflare-ai-gateway`, `huggingface`, `kimi-coding`, - `minimax`, `minimax-portal`, `modelstudio`, `nvidia`, `qianfan`, - `qwen-portal`, `synthetic`, `together`, `venice`, `vercel-ai-gateway`, - `volcengine`, and `xiaomi`: plugin-owned catalogs only + `minimax-portal`, `modelstudio`, `nvidia`, `qianfan`, `qwen-portal`, + `synthetic`, `together`, `venice`, `vercel-ai-gateway`, and `volcengine`: + plugin-owned catalogs only +- `minimax` and `xiaomi`: plugin-owned catalogs plus usage auth/snapshot logic That covers providers that still fit OpenClaw's normal transports. A provider that needs a totally custom request executor is a separate, deeper extension diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index de162c2ab42..983c69f0a12 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -172,12 +172,15 @@ Important trust note: - Hugging Face provider catalog — bundled as `huggingface` (enabled by default) - Kilo Gateway provider runtime — bundled as `kilocode` (enabled by default) - Kimi Coding provider catalog — bundled as `kimi-coding` (enabled by default) -- MiniMax provider catalog — bundled as `minimax` (enabled by default) +- MiniMax provider catalog + usage — bundled as `minimax` (enabled by default) - MiniMax OAuth (provider auth + catalog) — bundled as `minimax-portal-auth` (enabled by default) +- Mistral provider capabilities — bundled as `mistral` (enabled by default) - Model Studio provider catalog — bundled as `modelstudio` (enabled by default) - Moonshot provider runtime — bundled as `moonshot` (enabled by default) - NVIDIA provider catalog — bundled as `nvidia` (enabled by default) - OpenAI Codex provider runtime — bundled as `openai-codex` (enabled by default) +- OpenCode Go provider capabilities — bundled as `opencode-go` (enabled by default) +- OpenCode Zen provider capabilities — bundled as `opencode` (enabled by default) - OpenRouter provider runtime — bundled as `openrouter` (enabled by default) - Qianfan provider catalog — bundled as `qianfan` (enabled by default) - Qwen OAuth (provider auth + catalog) — bundled as `qwen-portal-auth` (enabled by default) @@ -186,7 +189,8 @@ Important trust note: - Venice provider catalog — bundled as `venice` (enabled by default) - Vercel AI Gateway provider catalog — bundled as `vercel-ai-gateway` (enabled by default) - Volcengine provider catalog — bundled as `volcengine` (enabled by default) -- Xiaomi provider catalog — bundled as `xiaomi` (enabled by default) +- Xiaomi provider catalog + usage — bundled as `xiaomi` (enabled by default) +- Z.AI provider runtime — bundled as `zai` (enabled by default) - Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default) Native OpenClaw plugins are **TypeScript modules** loaded at runtime via jiti. @@ -202,7 +206,7 @@ Native OpenClaw plugins can register: - Background services - Context engines - Provider auth flows and model catalogs -- Provider runtime hooks for dynamic model ids, transport normalization, capability metadata, stream wrapping, cache TTL policy, and runtime auth exchange +- Provider runtime hooks for dynamic model ids, transport normalization, capability metadata, stream wrapping, cache TTL policy, runtime auth exchange, and usage/billing auth + snapshot resolution - Optional config validation - **Skills** (by listing `skills` directories in the plugin manifest) - **Auto-reply commands** (execute without invoking the AI agent) @@ -215,7 +219,7 @@ Tool authoring guide: [Plugin agent tools](/plugins/agent-tools). Provider plugins now have two layers: - config-time hooks: `catalog` / legacy `discovery` -- runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `isCacheTtlEligible`, `prepareRuntimeAuth` +- runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `isCacheTtlEligible`, `prepareRuntimeAuth`, `resolveUsageAuth`, `fetchUsageSnapshot` OpenClaw still owns the generic agent loop, failover, transcript handling, and tool policy. These hooks are the seam for provider-specific behavior without @@ -249,6 +253,12 @@ For model/provider plugins, OpenClaw uses hooks in this rough order: 10. `prepareRuntimeAuth` Exchanges a configured credential into the actual runtime token/key just before inference. +11. `resolveUsageAuth` + Resolves usage/billing credentials for `/usage` and related status + surfaces. +12. `fetchUsageSnapshot` + Fetches and normalizes provider-specific usage/quota snapshots after auth + is resolved. ### Which hook to use @@ -261,6 +271,8 @@ For model/provider plugins, OpenClaw uses hooks in this rough order: - `wrapStreamFn`: add provider-specific headers/payload/model compat patches while still using the normal `pi-ai` execution path - `isCacheTtlEligible`: decide whether provider/model pairs should use cache TTL metadata - `prepareRuntimeAuth`: exchange a configured credential into the actual short-lived runtime token/key used for requests +- `resolveUsageAuth`: resolve provider-owned credentials for usage/billing endpoints without hardcoding token parsing in core +- `fetchUsageSnapshot`: own provider-specific usage endpoint fetch/parsing while core keeps summary fan-out and formatting Rule of thumb: @@ -273,12 +285,14 @@ Rule of thumb: - provider needs request headers/body/model compat wrappers without a custom transport: use `wrapStreamFn` - provider needs proxy-specific cache TTL gating: use `isCacheTtlEligible` - provider needs a token exchange or short-lived request credential: use `prepareRuntimeAuth` +- provider needs custom usage/quota token parsing or a different usage credential: use `resolveUsageAuth` +- provider needs a provider-specific usage endpoint or payload parser: use `fetchUsageSnapshot` If the provider needs a fully custom wire protocol or custom request executor, that is a different class of extension. These hooks are for provider behavior that still runs on OpenClaw's normal inference loop. -### Example +### Provider Example ```ts api.registerProvider({ @@ -322,6 +336,13 @@ api.registerProvider({ expiresAt: exchanged.expiresAt, }; }, + resolveUsageAuth: async (ctx) => { + const auth = await ctx.resolveOAuthToken(); + return auth ? { token: auth.token } : null; + }, + fetchUsageSnapshot: async (ctx) => { + return await fetchExampleProxyUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn); + }, }); ``` @@ -331,12 +352,17 @@ api.registerProvider({ `prepareDynamicModel` because the provider is pass-through and may expose new model ids before OpenClaw's static catalog updates. - GitHub Copilot uses `catalog`, `resolveDynamicModel`, and - `capabilities` plus `prepareRuntimeAuth` because it needs model fallback - behavior, Claude transcript quirks, and a GitHub token -> Copilot token exchange. + `capabilities` plus `prepareRuntimeAuth` and `fetchUsageSnapshot` because it + needs model fallback behavior, Claude transcript quirks, a GitHub token -> + Copilot token exchange, and a provider-owned usage endpoint. - OpenAI Codex uses `catalog`, `resolveDynamicModel`, and - `normalizeResolvedModel` plus `prepareExtraParams` because it still runs on - core OpenAI transports but owns its transport/base URL normalization and - default transport choice. + `normalizeResolvedModel` plus `prepareExtraParams`, `resolveUsageAuth`, and + `fetchUsageSnapshot` because it still runs on core OpenAI transports but owns + its transport/base URL normalization, default transport choice, and ChatGPT + usage endpoint integration. +- Gemini CLI OAuth uses `resolveDynamicModel`, `resolveUsageAuth`, and + `fetchUsageSnapshot` because it owns Gemini 3.1 forward-compat fallback plus + the token parsing and quota endpoint wiring needed by `/usage`. - OpenRouter uses `capabilities`, `wrapStreamFn`, and `isCacheTtlEligible` to keep provider-specific request headers, routing metadata, reasoning patches, and prompt-cache policy out of core. @@ -346,10 +372,19 @@ api.registerProvider({ `isCacheTtlEligible` because it needs provider-owned request headers, reasoning payload normalization, Gemini transcript hints, and Anthropic cache-TTL gating. +- Z.AI uses `resolveDynamicModel`, `prepareExtraParams`, `wrapStreamFn`, + `isCacheTtlEligible`, `resolveUsageAuth`, and `fetchUsageSnapshot` because it + owns GLM-5 fallback, `tool_stream` defaults, and both usage auth + quota + fetching. +- Mistral, OpenCode Zen, and OpenCode Go use `capabilities` only to keep + transcript/tooling quirks out of core. - Catalog-only bundled providers such as `byteplus`, `cloudflare-ai-gateway`, - `huggingface`, `kimi-coding`, `minimax`, `minimax-portal`, `modelstudio`, - `nvidia`, `qianfan`, `qwen-portal`, `synthetic`, `together`, `venice`, - `vercel-ai-gateway`, `volcengine`, and `xiaomi` use `catalog` only. + `huggingface`, `kimi-coding`, `minimax-portal`, `modelstudio`, `nvidia`, + `qianfan`, `qwen-portal`, `synthetic`, `together`, `venice`, + `vercel-ai-gateway`, and `volcengine` use `catalog` only. +- MiniMax and Xiaomi use `catalog` plus usage hooks because their `/usage` + behavior is plugin-owned even though inference still runs through the shared + transports. ## Load pipeline From 1f68e6e89cfb83290ce2879eb83b06a970bef5ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 16:58:24 -0700 Subject: [PATCH 054/943] docs(plugins): unify bundle format explainer --- docs/plugins/bundles.md | 189 +++++++++++++++++++++++++--------------- 1 file changed, 118 insertions(+), 71 deletions(-) diff --git a/docs/plugins/bundles.md b/docs/plugins/bundles.md index 1756baca71d..b5f92f8f5ee 100644 --- a/docs/plugins/bundles.md +++ b/docs/plugins/bundles.md @@ -1,7 +1,7 @@ --- -summary: "Compatible Codex/Claude bundle formats: detection, mapping, and current OpenClaw support" +summary: "Unified bundle format guide for Codex, Claude, and Cursor bundles in OpenClaw" read_when: - - You want to install or debug a Codex/Claude-compatible bundle + - You want to install or debug a Codex, Claude, or Cursor-compatible bundle - You need to understand how OpenClaw maps bundle content into native features - You are documenting bundle compatibility or current support limits title: "Plugin Bundles" @@ -9,15 +9,17 @@ title: "Plugin Bundles" # Plugin bundles -OpenClaw supports three **compatible bundle formats** in addition to native -OpenClaw plugins: +OpenClaw supports one shared class of external plugin package: **bundle +plugins**. + +Today that means three closely related ecosystems: - Codex bundles - Claude bundles - Cursor bundles -OpenClaw shows both as `Format: bundle` in `openclaw plugins list`. Verbose -output and `openclaw plugins info ` also show the bundle subtype +OpenClaw shows all of them as `Format: bundle` in `openclaw plugins list`. +Verbose output and `openclaw plugins info ` also show the subtype (`codex`, `claude`, or `cursor`). Related: @@ -33,54 +35,36 @@ plugin. Today, OpenClaw does **not** execute bundle runtime code in-process. Instead, it detects known bundle files, reads the metadata, and maps supported bundle -content into native OpenClaw surfaces such as skills, hook packs, and embedded -Pi settings. +content into native OpenClaw surfaces such as skills, hook packs, MCP config, +and embedded Pi settings. That is the main trust boundary: - native OpenClaw plugin: runtime module executes in-process - bundle: metadata/content pack, with selective feature mapping -## Supported bundle formats +## Shared bundle model -### Codex bundles +Codex, Claude, and Cursor bundles are similar enough that OpenClaw treats them +as one normalized model. -Typical markers: +Shared idea: -- `.codex-plugin/plugin.json` -- optional `skills/` -- optional `hooks/` -- optional `.mcp.json` -- optional `.app.json` +- a small manifest file, or a default directory layout +- one or more content roots such as `skills/` or `commands/` +- optional tool/runtime metadata such as MCP, hooks, agents, or LSP +- install as a directory or archive, then enable in the normal plugin list -### Claude bundles +Common OpenClaw behavior: -OpenClaw supports both: +- detect the bundle subtype +- normalize it into one internal bundle record +- map supported parts into native OpenClaw features +- report unsupported parts as detected-but-not-wired capabilities -- manifest-based Claude bundles: `.claude-plugin/plugin.json` -- manifestless Claude bundles that use the default component layout - -Default Claude layout markers OpenClaw recognizes: - -- `skills/` -- `commands/` -- `agents/` -- `hooks/hooks.json` -- `.mcp.json` -- `.lsp.json` -- `settings.json` - -### Cursor bundles - -Typical markers: - -- `.cursor-plugin/plugin.json` -- optional `skills/` -- optional `.cursor/commands/` -- optional `.cursor/agents/` -- optional `.cursor/rules/` -- optional `.cursor/hooks.json` -- optional `.mcp.json` +In practice, most users do not need to think about the vendor-specific format +first. The more useful question is: which bundle surfaces does OpenClaw map +today? ## Detection order @@ -97,19 +81,17 @@ Practical effect: That avoids partially installing a dual-format package as a bundle and then loading it later as a native plugin. -## Current mapping +## What works today OpenClaw normalizes bundle metadata into one internal bundle record, then maps supported surfaces into existing native behavior. ### Supported now -#### Skills +#### Skill content -- Codex `skills` roots load as normal OpenClaw skill roots -- Claude `skills` roots load as normal OpenClaw skill roots +- bundle skill roots load as normal OpenClaw skill roots - Claude `commands` roots are treated as additional skill roots -- Cursor `skills` roots load as normal OpenClaw skill roots - Cursor `.cursor/commands` roots are treated as additional skill roots This means Claude markdown command files work through the normal OpenClaw skill @@ -117,11 +99,17 @@ loader. Cursor command markdown works through the same path. #### Hook packs -- Codex `hooks` roots work **only** when they use the normal OpenClaw hook-pack - layout: +- bundle hook roots work **only** when they use the normal OpenClaw hook-pack + layout. Today this is primarily the Codex-compatible case: - `HOOK.md` - `handler.ts` or `handler.js` +#### MCP for CLI backends + +- enabled bundles can contribute MCP server config +- current runtime wiring is used by the `claude-cli` backend +- OpenClaw merges bundle MCP config into the backend `--mcp-config` file + #### Embedded Pi settings - Claude `settings.json` is imported as default embedded Pi settings when the @@ -140,16 +128,94 @@ diagnostics/info output, but OpenClaw does not run them yet: - Claude `agents` - Claude `hooks.json` automation -- Claude `mcpServers` - Claude `lspServers` - Claude `outputStyles` - Cursor `.cursor/agents` - Cursor `.cursor/hooks.json` - Cursor `.cursor/rules` -- Cursor `mcpServers` +- Cursor `mcpServers` outside the current mapped runtime paths - Codex inline/app metadata beyond capability reporting -## Claude path behavior +## Capability reporting + +`openclaw plugins info ` shows bundle capabilities from the normalized +bundle record. + +Supported capabilities are loaded quietly. Unsupported capabilities produce a +warning such as: + +```text +bundle capability detected but not wired into OpenClaw yet: agents +``` + +Current exceptions: + +- Claude `commands` is considered supported because it maps to skills +- Claude `settings` is considered supported because it maps to embedded Pi settings +- Cursor `commands` is considered supported because it maps to skills +- bundle MCP is considered supported where OpenClaw actually imports it +- Codex `hooks` is considered supported only for OpenClaw hook-pack layouts + +## Format differences + +The formats are close, but not byte-for-byte identical. These are the practical +differences that matter in OpenClaw. + +### Codex + +Typical markers: + +- `.codex-plugin/plugin.json` +- optional `skills/` +- optional `hooks/` +- optional `.mcp.json` +- optional `.app.json` + +Codex bundles fit OpenClaw best when they use skill roots and OpenClaw-style +hook-pack directories. + +### Claude + +OpenClaw supports both: + +- manifest-based Claude bundles: `.claude-plugin/plugin.json` +- manifestless Claude bundles that use the default Claude layout + +Default Claude layout markers OpenClaw recognizes: + +- `skills/` +- `commands/` +- `agents/` +- `hooks/hooks.json` +- `.mcp.json` +- `.lsp.json` +- `settings.json` + +Claude-specific notes: + +- `commands/` is treated like skill content +- `settings.json` is imported into embedded Pi settings +- `hooks/hooks.json` is detected, but not executed as Claude automation + +### Cursor + +Typical markers: + +- `.cursor-plugin/plugin.json` +- optional `skills/` +- optional `.cursor/commands/` +- optional `.cursor/agents/` +- optional `.cursor/rules/` +- optional `.cursor/hooks.json` +- optional `.mcp.json` + +Cursor-specific notes: + +- `.cursor/commands/` is treated like skill content +- `.cursor/rules/`, `.cursor/agents/`, and `.cursor/hooks.json` are + detect-only today + +## Claude custom paths Claude bundle manifests can declare custom component paths. OpenClaw treats those paths as **additive**, not replacing defaults. @@ -171,25 +237,6 @@ Examples: - default `skills/` plus manifest `skills: ["team-skills"]` => OpenClaw scans both -## Capability reporting - -`openclaw plugins info ` shows bundle capabilities from the normalized -bundle record. - -Supported capabilities are loaded quietly. Unsupported capabilities produce a -warning such as: - -```text -bundle capability detected but not wired into OpenClaw yet: agents -``` - -Current exceptions: - -- Claude `commands` is considered supported because it maps to skills -- Claude `settings` is considered supported because it maps to embedded Pi settings -- Cursor `commands` is considered supported because it maps to skills -- Codex `hooks` is considered supported only for OpenClaw hook-pack layouts - ## Security model Bundle support is intentionally narrower than native plugin support. From 70a228cdaa86a821c063acf9cc1bbfdc042c24a0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 00:06:28 +0000 Subject: [PATCH 055/943] fix: repair onboarding adapter registry imports --- src/commands/onboarding/registry.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts index 6d31199ea2a..f53e702c83e 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/onboarding/registry.ts @@ -1,14 +1,19 @@ -import { discordOnboardingAdapter } from "../../../extensions/discord/src/onboarding.js"; +import { discordOnboardingAdapter } from "../../../extensions/discord/src/setup-surface.js"; import { imessageOnboardingAdapter } from "../../../extensions/imessage/src/onboarding.js"; import { signalOnboardingAdapter } from "../../../extensions/signal/src/onboarding.js"; -import { slackOnboardingAdapter } from "../../../extensions/slack/src/onboarding.js"; -import { telegramOnboardingAdapter } from "../../../extensions/telegram/src/setup-surface.js"; +import { slackOnboardingAdapter } from "../../../extensions/slack/src/setup-surface.js"; +import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; import { whatsappOnboardingAdapter } from "../../../extensions/whatsapp/src/onboarding.js"; import { listChannelSetupPlugins } from "../../channels/plugins/setup-registry.js"; import { buildChannelOnboardingAdapterFromSetupWizard } from "../../channels/plugins/setup-wizard.js"; import type { ChannelChoice } from "../onboard-types.js"; import type { ChannelOnboardingAdapter } from "./types.js"; +const telegramOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: telegramPlugin, + wizard: telegramPlugin.setupWizard!, +}); + const BUILTIN_ONBOARDING_ADAPTERS: ChannelOnboardingAdapter[] = [ telegramOnboardingAdapter, whatsappOnboardingAdapter, From c6239bf2535803651267b14de8fed829aecbba3d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 17:06:15 -0700 Subject: [PATCH 056/943] refactor: expand setup wizard input flow --- src/channels/plugins/setup-wizard.ts | 283 ++++++++++++++++++++++++++- 1 file changed, 278 insertions(+), 5 deletions(-) diff --git a/src/channels/plugins/setup-wizard.ts b/src/channels/plugins/setup-wizard.ts index e19c2b57ee6..cb446a1bc76 100644 --- a/src/channels/plugins/setup-wizard.ts +++ b/src/channels/plugins/setup-wizard.ts @@ -3,6 +3,7 @@ import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; import type { ChannelOnboardingAdapter, + ChannelOnboardingConfigureContext, ChannelOnboardingDmPolicy, ChannelOnboardingStatus, ChannelOnboardingStatusContext, @@ -26,6 +27,18 @@ export type ChannelSetupWizardStatus = { configuredScore?: number; unconfiguredScore?: number; resolveConfigured: (params: { cfg: OpenClawConfig }) => boolean | Promise; + resolveStatusLines?: (params: { + cfg: OpenClawConfig; + configured: boolean; + }) => string[] | Promise; + resolveSelectionHint?: (params: { + cfg: OpenClawConfig; + configured: boolean; + }) => string | undefined | Promise; + resolveQuickstartScore?: (params: { + cfg: OpenClawConfig; + configured: boolean; + }) => number | undefined | Promise; }; export type ChannelSetupWizardCredentialState = { @@ -84,6 +97,51 @@ export type ChannelSetupWizardCredential = { }) => OpenClawConfig | Promise; }; +export type ChannelSetupWizardTextInput = { + inputKey: keyof ChannelSetupInput; + message: string; + placeholder?: string; + required?: boolean; + helpTitle?: string; + helpLines?: string[]; + confirmCurrentValue?: boolean; + keepPrompt?: string | ((value: string) => string); + currentValue?: (params: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: ChannelSetupWizardCredentialValues; + }) => string | undefined | Promise; + initialValue?: (params: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: ChannelSetupWizardCredentialValues; + }) => string | undefined | Promise; + shouldPrompt?: (params: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: ChannelSetupWizardCredentialValues; + currentValue?: string; + }) => boolean | Promise; + applyCurrentValue?: boolean; + validate?: (params: { + value: string; + cfg: OpenClawConfig; + accountId: string; + credentialValues: ChannelSetupWizardCredentialValues; + }) => string | undefined; + normalizeValue?: (params: { + value: string; + cfg: OpenClawConfig; + accountId: string; + credentialValues: ChannelSetupWizardCredentialValues; + }) => string; + applySet?: (params: { + cfg: OpenClawConfig; + accountId: string; + value: string; + }) => OpenClawConfig | Promise; +}; + export type ChannelSetupWizardAllowFromEntry = { input: string; resolved: boolean; @@ -139,12 +197,33 @@ export type ChannelSetupWizardGroupAccess = { }) => OpenClawConfig; }; +export type ChannelSetupWizardPrepare = (params: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: ChannelSetupWizardCredentialValues; + runtime: ChannelOnboardingConfigureContext["runtime"]; + prompter: WizardPrompter; + options?: ChannelOnboardingConfigureContext["options"]; +}) => + | { + cfg?: OpenClawConfig; + credentialValues?: ChannelSetupWizardCredentialValues; + } + | void + | Promise<{ + cfg?: OpenClawConfig; + credentialValues?: ChannelSetupWizardCredentialValues; + } | void>; + export type ChannelSetupWizard = { channel: string; status: ChannelSetupWizardStatus; introNote?: ChannelSetupWizardNote; envShortcut?: ChannelSetupWizardEnvShortcut; + prepare?: ChannelSetupWizardPrepare; credentials: ChannelSetupWizardCredential[]; + textInputs?: ChannelSetupWizardTextInput[]; + completionNote?: ChannelSetupWizardNote; dmPolicy?: ChannelOnboardingDmPolicy; allowFrom?: ChannelSetupWizardAllowFrom; groupAccess?: ChannelSetupWizardGroupAccess; @@ -160,14 +239,28 @@ async function buildStatus( ctx: ChannelOnboardingStatusContext, ): Promise { const configured = await wizard.status.resolveConfigured({ cfg: ctx.cfg }); + const statusLines = (await wizard.status.resolveStatusLines?.({ + cfg: ctx.cfg, + configured, + })) ?? [ + `${plugin.meta.label}: ${configured ? wizard.status.configuredLabel : wizard.status.unconfiguredLabel}`, + ]; + const selectionHint = + (await wizard.status.resolveSelectionHint?.({ + cfg: ctx.cfg, + configured, + })) ?? (configured ? wizard.status.configuredHint : wizard.status.unconfiguredHint); + const quickstartScore = + (await wizard.status.resolveQuickstartScore?.({ + cfg: ctx.cfg, + configured, + })) ?? (configured ? wizard.status.configuredScore : wizard.status.unconfiguredScore); return { channel: plugin.id, configured, - statusLines: [ - `${plugin.meta.label}: ${configured ? wizard.status.configuredLabel : wizard.status.unconfiguredLabel}`, - ], - selectionHint: configured ? wizard.status.configuredHint : wizard.status.unconfiguredHint, - quickstartScore: configured ? wizard.status.configuredScore : wizard.status.unconfiguredScore, + statusLines, + selectionHint, + quickstartScore, }; } @@ -238,6 +331,29 @@ function collectCredentialValues(params: { return values; } +async function applyWizardTextInputValue(params: { + plugin: ChannelSetupWizardPlugin; + input: ChannelSetupWizardTextInput; + cfg: OpenClawConfig; + accountId: string; + value: string; +}) { + return params.input.applySet + ? await params.input.applySet({ + cfg: params.cfg, + accountId: params.accountId, + value: params.value, + }) + : applySetupInput({ + plugin: params.plugin, + cfg: params.cfg, + accountId: params.accountId, + input: { + [params.input.inputKey]: params.value, + }, + }).cfg; +} + export function buildChannelOnboardingAdapterFromSetupWizard(params: { plugin: ChannelSetupWizardPlugin; wizard: ChannelSetupWizard; @@ -248,6 +364,7 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { getStatus: async (ctx) => buildStatus(plugin, wizard, ctx), configure: async ({ cfg, + runtime, prompter, options, accountOverrides, @@ -305,6 +422,26 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { await prompter.note(wizard.introNote.lines.join("\n"), wizard.introNote.title); } + if (wizard.prepare) { + const prepared = await wizard.prepare({ + cfg: next, + accountId, + credentialValues, + runtime, + prompter, + options, + }); + if (prepared?.cfg) { + next = prepared.cfg; + } + if (prepared?.credentialValues) { + credentialValues = { + ...credentialValues, + ...prepared.credentialValues, + }; + } + } + if (!usedEnvShortcut) { for (const credential of wizard.credentials) { let credentialState = credential.inspect({ cfg: next, accountId }); @@ -383,6 +520,129 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { } } + for (const textInput of wizard.textInputs ?? []) { + let currentValue = trimResolvedValue( + typeof credentialValues[textInput.inputKey] === "string" + ? credentialValues[textInput.inputKey] + : undefined, + ); + if (!currentValue && textInput.currentValue) { + currentValue = trimResolvedValue( + await textInput.currentValue({ + cfg: next, + accountId, + credentialValues, + }), + ); + } + const shouldPrompt = textInput.shouldPrompt + ? await textInput.shouldPrompt({ + cfg: next, + accountId, + credentialValues, + currentValue, + }) + : true; + + if (!shouldPrompt) { + if (currentValue) { + credentialValues[textInput.inputKey] = currentValue; + if (textInput.applyCurrentValue) { + next = await applyWizardTextInputValue({ + plugin, + input: textInput, + cfg: next, + accountId, + value: currentValue, + }); + } + } + continue; + } + + if (textInput.helpLines && textInput.helpLines.length > 0) { + await prompter.note( + textInput.helpLines.join("\n"), + textInput.helpTitle ?? textInput.message, + ); + } + + if (currentValue && textInput.confirmCurrentValue !== false) { + const keep = await prompter.confirm({ + message: + typeof textInput.keepPrompt === "function" + ? textInput.keepPrompt(currentValue) + : (textInput.keepPrompt ?? `${textInput.message} set (${currentValue}). Keep it?`), + initialValue: true, + }); + if (keep) { + credentialValues[textInput.inputKey] = currentValue; + if (textInput.applyCurrentValue) { + next = await applyWizardTextInputValue({ + plugin, + input: textInput, + cfg: next, + accountId, + value: currentValue, + }); + } + continue; + } + } + + const initialValue = trimResolvedValue( + (await textInput.initialValue?.({ + cfg: next, + accountId, + credentialValues, + })) ?? currentValue, + ); + const rawValue = String( + await prompter.text({ + message: textInput.message, + initialValue, + placeholder: textInput.placeholder, + validate: (value) => { + const trimmed = String(value ?? "").trim(); + if (!trimmed && textInput.required !== false) { + return "Required"; + } + return textInput.validate?.({ + value: trimmed, + cfg: next, + accountId, + credentialValues, + }); + }, + }), + ); + const trimmedValue = rawValue.trim(); + if (!trimmedValue && textInput.required === false) { + delete credentialValues[textInput.inputKey]; + continue; + } + const normalizedValue = trimResolvedValue( + textInput.normalizeValue?.({ + value: trimmedValue, + cfg: next, + accountId, + credentialValues, + }) ?? trimmedValue, + ); + if (!normalizedValue) { + delete credentialValues[textInput.inputKey]; + continue; + } + next = await applyWizardTextInputValue({ + plugin, + input: textInput, + cfg: next, + accountId, + value: normalizedValue, + }); + credentialValues[textInput.inputKey] = normalizedValue; + } + if (wizard.groupAccess) { const access = wizard.groupAccess; if (access.helpLines && access.helpLines.length > 0) { @@ -460,6 +720,19 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { }); } + const shouldShowCompletionNote = + wizard.completionNote && + (wizard.completionNote.shouldShow + ? await wizard.completionNote.shouldShow({ + cfg: next, + accountId, + credentialValues, + }) + : true); + if (shouldShowCompletionNote && wizard.completionNote) { + await prompter.note(wizard.completionNote.lines.join("\n"), wizard.completionNote.title); + } + return { cfg: next, accountId }; }, dmPolicy: wizard.dmPolicy, From 1f37203f88bc49869011a7ae544f737c6d6d8a62 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 17:06:23 -0700 Subject: [PATCH 057/943] refactor: move signal imessage mattermost to setup wizard --- extensions/imessage/src/channel.ts | 79 +---- extensions/imessage/src/onboarding.ts | 183 ---------- extensions/imessage/src/setup-surface.ts | 238 +++++++++++++ extensions/mattermost/src/channel.ts | 63 +--- .../mattermost/src/onboarding.status.test.ts | 7 +- extensions/mattermost/src/onboarding.ts | 190 ----------- extensions/mattermost/src/setup-surface.ts | 193 +++++++++++ extensions/signal/src/channel.ts | 96 +----- extensions/signal/src/onboarding.ts | 254 -------------- extensions/signal/src/setup-surface.ts | 312 ++++++++++++++++++ .../plugins/onboarding/imessage.test.ts | 2 +- .../plugins/onboarding/signal.test.ts | 2 +- src/channels/plugins/plugins-channel.test.ts | 2 +- src/plugin-sdk/imessage.ts | 5 +- src/plugin-sdk/index.ts | 10 +- src/plugin-sdk/signal.ts | 5 +- src/plugin-sdk/subpaths.test.ts | 6 +- 17 files changed, 779 insertions(+), 868 deletions(-) delete mode 100644 extensions/imessage/src/onboarding.ts create mode 100644 extensions/imessage/src/setup-surface.ts delete mode 100644 extensions/mattermost/src/onboarding.ts create mode 100644 extensions/mattermost/src/setup-surface.ts delete mode 100644 extensions/signal/src/onboarding.ts create mode 100644 extensions/signal/src/setup-surface.ts diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index ff3758bf0d6..5760d1c2fb3 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -3,19 +3,15 @@ import { collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/compat"; import { - applyAccountNameToChannelSection, buildChannelConfigSchema, collectStatusIssuesFromLastError, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, formatTrimmedAllowFromEntries, getChatChannelMeta, - imessageOnboardingAdapter, IMessageConfigSchema, listIMessageAccountIds, looksLikeIMessageTargetId, - migrateBaseNameToDefaultAccount, - normalizeAccountId, normalizeIMessageMessagingTarget, PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, @@ -32,23 +28,10 @@ import { import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { getIMessageRuntime } from "./runtime.js"; +import { imessageSetupAdapter, imessageSetupWizard } from "./setup-surface.js"; const meta = getChatChannelMeta("imessage"); -function buildIMessageSetupPatch(input: { - cliPath?: string; - dbPath?: string; - service?: string; - region?: string; -}) { - return { - ...(input.cliPath ? { cliPath: input.cliPath } : {}), - ...(input.dbPath ? { dbPath: input.dbPath } : {}), - ...(input.service ? { service: input.service } : {}), - ...(input.region ? { region: input.region } : {}), - }; -} - type IMessageSendFn = ReturnType< typeof getIMessageRuntime >["channel"]["imessage"]["sendMessageIMessage"]; @@ -90,7 +73,7 @@ export const imessagePlugin: ChannelPlugin = { aliases: ["imsg"], showConfigured: false, }, - onboarding: imessageOnboardingAdapter, + setupWizard: imessageSetupWizard, pairing: { idLabel: "imessageSenderId", notifyApproval: async ({ id }) => { @@ -169,63 +152,7 @@ export const imessagePlugin: ChannelPlugin = { hint: "", }, }, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: "imessage", - accountId, - name, - }), - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: "imessage", - accountId, - name: input.name, - }); - const next = ( - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "imessage", - }) - : namedConfig - ) as typeof cfg; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - imessage: { - ...next.channels?.imessage, - enabled: true, - ...buildIMessageSetupPatch(input), - }, - }, - } as typeof cfg; - } - 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), - }, - }, - }, - }, - } as typeof cfg; - }, - }, + setup: imessageSetupAdapter, outbound: { deliveryMode: "direct", chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit), diff --git a/extensions/imessage/src/onboarding.ts b/extensions/imessage/src/onboarding.ts deleted file mode 100644 index 85b3dc43be4..00000000000 --- a/extensions/imessage/src/onboarding.ts +++ /dev/null @@ -1,183 +0,0 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../../../src/channels/plugins/onboarding-types.js"; -import { - parseOnboardingEntriesAllowingWildcard, - patchChannelConfigForAccount, - promptParsedAllowFromForScopedChannel, - resolveAccountIdForConfigure, - setChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; -import { detectBinary } from "../../../src/commands/onboard-helpers.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; -import { - listIMessageAccountIds, - resolveDefaultIMessageAccountId, - resolveIMessageAccount, -} from "./accounts.js"; -import { normalizeIMessageHandle } from "./targets.js"; - -const channel = "imessage" as const; - -export function parseIMessageAllowFromEntries(raw: string): { entries: string[]; error?: string } { - return parseOnboardingEntriesAllowingWildcard(raw, (entry) => { - const lower = entry.toLowerCase(); - if (lower.startsWith("chat_id:")) { - const id = entry.slice("chat_id:".length).trim(); - if (!/^\d+$/.test(id)) { - return { error: `Invalid chat_id: ${entry}` }; - } - return { value: entry }; - } - if (lower.startsWith("chat_guid:")) { - if (!entry.slice("chat_guid:".length).trim()) { - return { error: "Invalid chat_guid entry" }; - } - return { value: entry }; - } - if (lower.startsWith("chat_identifier:")) { - if (!entry.slice("chat_identifier:".length).trim()) { - return { error: "Invalid chat_identifier entry" }; - } - return { value: entry }; - } - if (!normalizeIMessageHandle(entry)) { - return { error: `Invalid handle: ${entry}` }; - } - return { value: entry }; - }); -} - -async function promptIMessageAllowFrom(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - return promptParsedAllowFromForScopedChannel({ - cfg: params.cfg, - channel: "imessage", - accountId: params.accountId, - defaultAccountId: resolveDefaultIMessageAccountId(params.cfg), - prompter: params.prompter, - noteTitle: "iMessage allowlist", - noteLines: [ - "Allowlist iMessage DMs by handle or chat target.", - "Examples:", - "- +15555550123", - "- user@example.com", - "- chat_id:123", - "- chat_guid:... or chat_identifier:...", - "Multiple entries: comma-separated.", - `Docs: ${formatDocsLink("/imessage", "imessage")}`, - ], - message: "iMessage allowFrom (handle or chat_id)", - placeholder: "+15555550123, user@example.com, chat_id:123", - parseEntries: parseIMessageAllowFromEntries, - getExistingAllowFrom: ({ cfg, accountId }) => { - const resolved = resolveIMessageAccount({ cfg, accountId }); - return resolved.config.allowFrom ?? []; - }, - }); -} - -const dmPolicy: ChannelOnboardingDmPolicy = { - label: "iMessage", - channel, - policyKey: "channels.imessage.dmPolicy", - allowFromKey: "channels.imessage.allowFrom", - getCurrent: (cfg) => cfg.channels?.imessage?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => - setChannelDmPolicyWithAllowFrom({ - cfg, - channel: "imessage", - dmPolicy: policy, - }), - promptAllowFrom: promptIMessageAllowFrom, -}; - -export const imessageOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const configured = 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, - ); - }); - const imessageCliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; - const imessageCliDetected = await detectBinary(imessageCliPath); - return { - channel, - configured, - statusLines: [ - `iMessage: ${configured ? "configured" : "needs setup"}`, - `imsg: ${imessageCliDetected ? "found" : "missing"} (${imessageCliPath})`, - ], - selectionHint: imessageCliDetected ? "imsg found" : "imsg missing", - quickstartScore: imessageCliDetected ? 1 : 0, - }; - }, - configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { - const defaultIMessageAccountId = resolveDefaultIMessageAccountId(cfg); - const imessageAccountId = await resolveAccountIdForConfigure({ - cfg, - prompter, - label: "iMessage", - accountOverride: accountOverrides.imessage, - shouldPromptAccountIds, - listAccountIds: listIMessageAccountIds, - defaultAccountId: defaultIMessageAccountId, - }); - - let next = cfg; - const resolvedAccount = resolveIMessageAccount({ - cfg: next, - accountId: imessageAccountId, - }); - let resolvedCliPath = resolvedAccount.config.cliPath ?? "imsg"; - const cliDetected = await detectBinary(resolvedCliPath); - if (!cliDetected) { - const entered = await prompter.text({ - message: "imsg CLI path", - initialValue: resolvedCliPath, - validate: (value) => (value?.trim() ? undefined : "Required"), - }); - resolvedCliPath = String(entered).trim(); - if (!resolvedCliPath) { - await prompter.note("imsg CLI path required to enable iMessage.", "iMessage"); - } - } - - if (resolvedCliPath) { - next = patchChannelConfigForAccount({ - cfg: next, - channel: "imessage", - accountId: imessageAccountId, - patch: { cliPath: resolvedCliPath }, - }); - } - - await prompter.note( - [ - "This is still a work in progress.", - "Ensure OpenClaw has Full Disk Access to Messages DB.", - "Grant Automation permission for Messages when prompted.", - "List chats with: imsg chats --limit 20", - `Docs: ${formatDocsLink("/imessage", "imessage")}`, - ].join("\n"), - "iMessage next steps", - ); - - return { cfg: next, accountId: imessageAccountId }; - }, - dmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), -}; diff --git a/extensions/imessage/src/setup-surface.ts b/extensions/imessage/src/setup-surface.ts new file mode 100644 index 00000000000..69382ff4014 --- /dev/null +++ b/extensions/imessage/src/setup-surface.ts @@ -0,0 +1,238 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + parseOnboardingEntriesAllowingWildcard, + promptParsedAllowFromForScopedChannel, + setChannelDmPolicyWithAllowFrom, + setOnboardingChannelEnabled, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { detectBinary } from "../../../src/commands/onboard-helpers.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { + listIMessageAccountIds, + resolveDefaultIMessageAccountId, + resolveIMessageAccount, +} from "./accounts.js"; +import { normalizeIMessageHandle } from "./targets.js"; + +const channel = "imessage" as const; + +export function parseIMessageAllowFromEntries(raw: string): { entries: string[]; error?: string } { + return parseOnboardingEntriesAllowingWildcard(raw, (entry) => { + const lower = entry.toLowerCase(); + if (lower.startsWith("chat_id:")) { + const id = entry.slice("chat_id:".length).trim(); + if (!/^\d+$/.test(id)) { + return { error: `Invalid chat_id: ${entry}` }; + } + return { value: entry }; + } + if (lower.startsWith("chat_guid:")) { + if (!entry.slice("chat_guid:".length).trim()) { + return { error: "Invalid chat_guid entry" }; + } + return { value: entry }; + } + if (lower.startsWith("chat_identifier:")) { + if (!entry.slice("chat_identifier:".length).trim()) { + return { error: "Invalid chat_identifier entry" }; + } + return { value: entry }; + } + if (!normalizeIMessageHandle(entry)) { + return { error: `Invalid handle: ${entry}` }; + } + return { value: entry }; + }); +} + +function buildIMessageSetupPatch(input: { + cliPath?: string; + dbPath?: string; + service?: "imessage" | "sms" | "auto"; + region?: string; +}) { + return { + ...(input.cliPath ? { cliPath: input.cliPath } : {}), + ...(input.dbPath ? { dbPath: input.dbPath } : {}), + ...(input.service ? { service: input.service } : {}), + ...(input.region ? { region: input.region } : {}), + }; +} + +async function promptIMessageAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + return promptParsedAllowFromForScopedChannel({ + cfg: params.cfg, + channel, + accountId: params.accountId, + defaultAccountId: resolveDefaultIMessageAccountId(params.cfg), + prompter: params.prompter, + noteTitle: "iMessage allowlist", + noteLines: [ + "Allowlist iMessage DMs by handle or chat target.", + "Examples:", + "- +15555550123", + "- user@example.com", + "- chat_id:123", + "- chat_guid:... or chat_identifier:...", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/imessage", "imessage")}`, + ], + message: "iMessage allowFrom (handle or chat_id)", + placeholder: "+15555550123, user@example.com, chat_id:123", + parseEntries: parseIMessageAllowFromEntries, + getExistingAllowFrom: ({ cfg, accountId }) => + resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? [], + }); +} + +const imessageDmPolicy: ChannelOnboardingDmPolicy = { + label: "iMessage", + channel, + policyKey: "channels.imessage.dmPolicy", + allowFromKey: "channels.imessage.allowFrom", + getCurrent: (cfg) => cfg.channels?.imessage?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => + setChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), + promptAllowFrom: promptIMessageAllowFrom, +}; + +export const imessageSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + imessage: { + ...next.channels?.imessage, + enabled: true, + ...buildIMessageSetupPatch(input), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + imessage: { + ...next.channels?.imessage, + enabled: true, + accounts: { + ...next.channels?.imessage?.accounts, + [accountId]: { + ...next.channels?.imessage?.accounts?.[accountId], + enabled: true, + ...buildIMessageSetupPatch(input), + }, + }, + }, + }, + }; + }, +}; + +export const imessageSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + configuredHint: "imsg found", + unconfiguredHint: "imsg missing", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => + listIMessageAccountIds(cfg).some((accountId) => { + const account = resolveIMessageAccount({ cfg, accountId }); + return Boolean( + account.config.cliPath || + account.config.dbPath || + account.config.allowFrom || + account.config.service || + account.config.region, + ); + }), + resolveStatusLines: async ({ cfg, configured }) => { + const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; + const cliDetected = await detectBinary(cliPath); + return [ + `iMessage: ${configured ? "configured" : "needs setup"}`, + `imsg: ${cliDetected ? "found" : "missing"} (${cliPath})`, + ]; + }, + resolveSelectionHint: async ({ cfg }) => { + const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; + return (await detectBinary(cliPath)) ? "imsg found" : "imsg missing"; + }, + resolveQuickstartScore: async ({ cfg }) => { + const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; + return (await detectBinary(cliPath)) ? 1 : 0; + }, + }, + credentials: [], + textInputs: [ + { + inputKey: "cliPath", + message: "imsg CLI path", + initialValue: ({ cfg, accountId }) => + resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", + currentValue: ({ cfg, accountId }) => + resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", + shouldPrompt: async ({ currentValue }) => !(await detectBinary(currentValue ?? "imsg")), + confirmCurrentValue: false, + applyCurrentValue: true, + helpTitle: "iMessage", + helpLines: ["imsg CLI path required to enable iMessage."], + }, + ], + completionNote: { + title: "iMessage next steps", + lines: [ + "This is still a work in progress.", + "Ensure OpenClaw has Full Disk Access to Messages DB.", + "Grant Automation permission for Messages when prompted.", + "List chats with: imsg chats --limit 20", + `Docs: ${formatDocsLink("/imessage", "imessage")}`, + ], + }, + dmPolicy: imessageDmPolicy, + disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), +}; diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 45c4d863c7c..b28766d6db9 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -5,15 +5,11 @@ import { formatNormalizedAllowFromEntries, } from "openclaw/plugin-sdk/compat"; import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, buildComputedAccountStatusSnapshot, buildChannelConfigSchema, createAccountStatusSink, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, - migrateBaseNameToDefaultAccount, - normalizeAccountId, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, setAccountEnabledInConfigSection, @@ -31,7 +27,6 @@ import { resolveMattermostReplyToMode, type ResolvedMattermostAccount, } from "./mattermost/accounts.js"; -import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; import { listMattermostDirectoryGroups, listMattermostDirectoryPeers, @@ -42,8 +37,8 @@ import { addMattermostReaction, removeMattermostReaction } from "./mattermost/re import { sendMessageMattermost } from "./mattermost/send.js"; import { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js"; import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js"; -import { mattermostOnboardingAdapter } from "./onboarding.js"; import { getMattermostRuntime } from "./runtime.js"; +import { mattermostSetupAdapter, mattermostSetupWizard } from "./setup-surface.js"; const mattermostMessageActions: ChannelMessageActionAdapter = { listActions: ({ cfg }) => { @@ -256,7 +251,8 @@ export const mattermostPlugin: ChannelPlugin = { meta: { ...meta, }, - onboarding: mattermostOnboardingAdapter, + setup: mattermostSetupAdapter, + setupWizard: mattermostSetupWizard, pairing: { idLabel: "mattermostUserId", normalizeAllowEntry: (entry) => normalizeAllowEntry(entry), @@ -462,59 +458,6 @@ export const mattermostPlugin: ChannelPlugin = { }; }, }, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: "mattermost", - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "Mattermost env vars can only be used for the default account."; - } - const token = input.botToken ?? input.token; - const baseUrl = input.httpUrl; - if (!input.useEnv && (!token || !baseUrl)) { - return "Mattermost requires --bot-token and --http-url (or --use-env)."; - } - if (baseUrl && !normalizeMattermostBaseUrl(baseUrl)) { - return "Mattermost --http-url must include a valid base URL."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const token = input.botToken ?? input.token; - const baseUrl = input.httpUrl?.trim(); - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: "mattermost", - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "mattermost", - }) - : namedConfig; - const patch = input.useEnv - ? {} - : { - ...(token ? { botToken: token } : {}), - ...(baseUrl ? { baseUrl } : {}), - }; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: "mattermost", - accountId, - patch, - }); - }, - }, gateway: { startAccount: async (ctx) => { const account = ctx.account; diff --git a/extensions/mattermost/src/onboarding.status.test.ts b/extensions/mattermost/src/onboarding.status.test.ts index af0e9be5b00..023ea48cfa8 100644 --- a/extensions/mattermost/src/onboarding.status.test.ts +++ b/extensions/mattermost/src/onboarding.status.test.ts @@ -1,10 +1,10 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost"; import { describe, expect, it } from "vitest"; -import { mattermostOnboardingAdapter } from "./onboarding.js"; +import { mattermostSetupWizard } from "./setup-surface.js"; describe("mattermost onboarding status", () => { it("treats SecretRef botToken as configured when baseUrl is present", async () => { - const status = await mattermostOnboardingAdapter.getStatus({ + const configured = await mattermostSetupWizard.status.resolveConfigured({ cfg: { channels: { mattermost: { @@ -17,9 +17,8 @@ describe("mattermost onboarding status", () => { }, }, } as OpenClawConfig, - accountOverrides: {}, }); - expect(status.configured).toBe(true); + expect(configured).toBe(true); }); }); diff --git a/extensions/mattermost/src/onboarding.ts b/extensions/mattermost/src/onboarding.ts deleted file mode 100644 index 67f9cc2362e..00000000000 --- a/extensions/mattermost/src/onboarding.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; -import { - buildSingleChannelSecretPromptState, - hasConfiguredSecretInput, - promptSingleChannelSecretInput, - type ChannelOnboardingAdapter, - type OpenClawConfig, - type SecretInput, - type WizardPrompter, -} from "openclaw/plugin-sdk/mattermost"; -import { - listMattermostAccountIds, - resolveDefaultMattermostAccountId, - resolveMattermostAccount, -} from "./mattermost/accounts.js"; -import { resolveAccountIdForConfigure } from "./onboarding-helpers.js"; - -const channel = "mattermost" as const; - -async function noteMattermostSetup(prompter: WizardPrompter): Promise { - await prompter.note( - [ - "1) Mattermost System Console -> Integrations -> Bot Accounts", - "2) Create a bot + copy its token", - "3) Use your server base URL (e.g., https://chat.example.com)", - "Tip: the bot must be a member of any channel you want it to monitor.", - "Docs: https://docs.openclaw.ai/channels/mattermost", - ].join("\n"), - "Mattermost bot token", - ); -} - -async function promptMattermostBaseUrl(params: { - prompter: WizardPrompter; - initialValue?: string; -}): Promise { - const baseUrl = String( - await params.prompter.text({ - message: "Enter Mattermost base URL", - initialValue: params.initialValue, - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - return baseUrl; -} - -export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const configured = listMattermostAccountIds(cfg).some((accountId) => { - const account = resolveMattermostAccount({ - cfg, - accountId, - allowUnresolvedSecretRef: true, - }); - const tokenConfigured = - Boolean(account.botToken) || hasConfiguredSecretInput(account.config.botToken); - return tokenConfigured && Boolean(account.baseUrl); - }); - return { - channel, - configured, - statusLines: [`Mattermost: ${configured ? "configured" : "needs token + url"}`], - selectionHint: configured ? "configured" : "needs setup", - quickstartScore: configured ? 2 : 1, - }; - }, - configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { - const defaultAccountId = resolveDefaultMattermostAccountId(cfg); - const accountId = await resolveAccountIdForConfigure({ - cfg, - prompter, - label: "Mattermost", - accountOverride: accountOverrides.mattermost, - shouldPromptAccountIds, - listAccountIds: listMattermostAccountIds, - defaultAccountId, - }); - - let next = cfg; - const resolvedAccount = resolveMattermostAccount({ - cfg: next, - accountId, - allowUnresolvedSecretRef: true, - }); - const accountConfigured = Boolean(resolvedAccount.botToken && resolvedAccount.baseUrl); - const allowEnv = accountId === DEFAULT_ACCOUNT_ID; - const hasConfigToken = hasConfiguredSecretInput(resolvedAccount.config.botToken); - const hasConfigValues = hasConfigToken || Boolean(resolvedAccount.config.baseUrl); - const tokenPromptState = buildSingleChannelSecretPromptState({ - accountConfigured, - hasConfigToken, - allowEnv: allowEnv && !hasConfigValues, - envValue: - process.env.MATTERMOST_BOT_TOKEN?.trim() && process.env.MATTERMOST_URL?.trim() - ? process.env.MATTERMOST_BOT_TOKEN - : undefined, - }); - - let botToken: SecretInput | null = null; - let baseUrl: string | null = null; - - if (!accountConfigured) { - await noteMattermostSetup(prompter); - } - - const botTokenResult = await promptSingleChannelSecretInput({ - cfg: next, - prompter, - providerHint: "mattermost", - credentialLabel: "bot token", - accountConfigured: tokenPromptState.accountConfigured, - canUseEnv: tokenPromptState.canUseEnv, - hasConfigToken: tokenPromptState.hasConfigToken, - envPrompt: "MATTERMOST_BOT_TOKEN + MATTERMOST_URL detected. Use env vars?", - keepPrompt: "Mattermost bot token already configured. Keep it?", - inputPrompt: "Enter Mattermost bot token", - preferredEnvVar: "MATTERMOST_BOT_TOKEN", - }); - if (botTokenResult.action === "keep") { - return { cfg: next, accountId }; - } - - if (botTokenResult.action === "use-env") { - if (accountId === DEFAULT_ACCOUNT_ID) { - next = { - ...next, - channels: { - ...next.channels, - mattermost: { - ...next.channels?.mattermost, - enabled: true, - }, - }, - }; - } - return { cfg: next, accountId }; - } - - botToken = botTokenResult.value; - baseUrl = await promptMattermostBaseUrl({ - prompter, - initialValue: resolvedAccount.baseUrl ?? process.env.MATTERMOST_URL?.trim(), - }); - - if (accountId === DEFAULT_ACCOUNT_ID) { - next = { - ...next, - channels: { - ...next.channels, - mattermost: { - ...next.channels?.mattermost, - enabled: true, - botToken, - baseUrl, - }, - }, - }; - } else { - next = { - ...next, - channels: { - ...next.channels, - mattermost: { - ...next.channels?.mattermost, - enabled: true, - accounts: { - ...next.channels?.mattermost?.accounts, - [accountId]: { - ...next.channels?.mattermost?.accounts?.[accountId], - enabled: next.channels?.mattermost?.accounts?.[accountId]?.enabled ?? true, - botToken, - baseUrl, - }, - }, - }, - }, - }; - } - - return { cfg: next, accountId }; - }, - disable: (cfg: OpenClawConfig) => ({ - ...cfg, - channels: { - ...cfg.channels, - mattermost: { ...cfg.channels?.mattermost, enabled: false }, - }, - }), -}; diff --git a/extensions/mattermost/src/setup-surface.ts b/extensions/mattermost/src/setup-surface.ts new file mode 100644 index 00000000000..a201a24d82f --- /dev/null +++ b/extensions/mattermost/src/setup-surface.ts @@ -0,0 +1,193 @@ +import { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + DEFAULT_ACCOUNT_ID, + hasConfiguredSecretInput, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + type OpenClawConfig, +} from "openclaw/plugin-sdk/mattermost"; +import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { + listMattermostAccountIds, + resolveMattermostAccount, + type ResolvedMattermostAccount, +} from "./mattermost/accounts.js"; +import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; + +const channel = "mattermost" as const; + +function isMattermostConfigured(account: ResolvedMattermostAccount): boolean { + const tokenConfigured = + Boolean(account.botToken?.trim()) || hasConfiguredSecretInput(account.config.botToken); + return tokenConfigured && Boolean(account.baseUrl); +} + +function resolveMattermostAccountWithSecrets(cfg: OpenClawConfig, accountId: string) { + return resolveMattermostAccount({ + cfg, + accountId, + allowUnresolvedSecretRef: true, + }); +} + +export const mattermostSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + const token = input.botToken ?? input.token; + const baseUrl = normalizeMattermostBaseUrl(input.httpUrl); + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "Mattermost env vars can only be used for the default account."; + } + if (!input.useEnv && (!token || !baseUrl)) { + return "Mattermost requires --bot-token and --http-url (or --use-env)."; + } + if (input.httpUrl && !baseUrl) { + return "Mattermost --http-url must include a valid base URL."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const token = input.botToken ?? input.token; + const baseUrl = normalizeMattermostBaseUrl(input.httpUrl); + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + return applySetupAccountConfigPatch({ + cfg: next, + channelKey: channel, + accountId, + patch: input.useEnv + ? {} + : { + ...(token ? { botToken: token } : {}), + ...(baseUrl ? { baseUrl } : {}), + }, + }); + }, +}; + +export const mattermostSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs token + url", + configuredHint: "configured", + unconfiguredHint: "needs setup", + configuredScore: 2, + unconfiguredScore: 1, + resolveConfigured: ({ cfg }) => + listMattermostAccountIds(cfg).some((accountId) => + isMattermostConfigured(resolveMattermostAccountWithSecrets(cfg, accountId)), + ), + }, + introNote: { + title: "Mattermost bot token", + lines: [ + "1) Mattermost System Console -> Integrations -> Bot Accounts", + "2) Create a bot + copy its token", + "3) Use your server base URL (e.g., https://chat.example.com)", + "Tip: the bot must be a member of any channel you want it to monitor.", + `Docs: ${formatDocsLink("/mattermost", "mattermost")}`, + ], + shouldShow: ({ cfg, accountId }) => + !isMattermostConfigured(resolveMattermostAccountWithSecrets(cfg, accountId)), + }, + envShortcut: { + prompt: "MATTERMOST_BOT_TOKEN + MATTERMOST_URL detected. Use env vars?", + preferredEnvVar: "MATTERMOST_BOT_TOKEN", + isAvailable: ({ cfg, accountId }) => { + if (accountId !== DEFAULT_ACCOUNT_ID) { + return false; + } + const resolvedAccount = resolveMattermostAccountWithSecrets(cfg, accountId); + const hasConfigValues = + hasConfiguredSecretInput(resolvedAccount.config.botToken) || + Boolean(resolvedAccount.config.baseUrl?.trim()); + return Boolean( + process.env.MATTERMOST_BOT_TOKEN?.trim() && + process.env.MATTERMOST_URL?.trim() && + !hasConfigValues, + ); + }, + apply: ({ cfg, accountId }) => + applySetupAccountConfigPatch({ + cfg, + channelKey: channel, + accountId, + patch: {}, + }), + }, + credentials: [ + { + inputKey: "botToken", + providerHint: channel, + credentialLabel: "bot token", + preferredEnvVar: "MATTERMOST_BOT_TOKEN", + envPrompt: "MATTERMOST_BOT_TOKEN + MATTERMOST_URL detected. Use env vars?", + keepPrompt: "Mattermost bot token already configured. Keep it?", + inputPrompt: "Enter Mattermost bot token", + inspect: ({ cfg, accountId }) => { + const resolvedAccount = resolveMattermostAccountWithSecrets(cfg, accountId); + return { + accountConfigured: isMattermostConfigured(resolvedAccount), + hasConfiguredValue: hasConfiguredSecretInput(resolvedAccount.config.botToken), + }; + }, + }, + ], + textInputs: [ + { + inputKey: "httpUrl", + message: "Enter Mattermost base URL", + confirmCurrentValue: false, + currentValue: ({ cfg, accountId }) => + resolveMattermostAccountWithSecrets(cfg, accountId).baseUrl ?? + process.env.MATTERMOST_URL?.trim(), + initialValue: ({ cfg, accountId }) => + resolveMattermostAccountWithSecrets(cfg, accountId).baseUrl ?? + process.env.MATTERMOST_URL?.trim(), + shouldPrompt: ({ cfg, accountId, credentialValues, currentValue }) => { + const resolvedAccount = resolveMattermostAccountWithSecrets(cfg, accountId); + const tokenConfigured = + Boolean(resolvedAccount.botToken?.trim()) || + hasConfiguredSecretInput(resolvedAccount.config.botToken); + return Boolean(credentialValues.botToken) || !tokenConfigured || !currentValue; + }, + validate: ({ value }) => + normalizeMattermostBaseUrl(value) + ? undefined + : "Mattermost base URL must include a valid base URL.", + normalizeValue: ({ value }) => normalizeMattermostBaseUrl(value) ?? value.trim(), + }, + ], + disable: (cfg: OpenClawConfig) => ({ + ...cfg, + channels: { + ...cfg.channels, + mattermost: { + ...cfg.channels?.mattermost, + enabled: false, + }, + }, + }), +}; diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 7b1f3e5493a..ccf635e60cf 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -4,7 +4,6 @@ import { collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/compat"; import { - applyAccountNameToChannelSection, buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, buildChannelConfigSchema, @@ -15,8 +14,6 @@ import { getChatChannelMeta, listSignalAccountIds, looksLikeSignalTargetId, - migrateBaseNameToDefaultAccount, - normalizeAccountId, normalizeE164, normalizeSignalMessagingTarget, PAIRING_APPROVED_MESSAGE, @@ -24,7 +21,6 @@ import { resolveDefaultSignalAccountId, resolveSignalAccount, setAccountEnabledInConfigSection, - signalOnboardingAdapter, SignalConfigSchema, type ChannelMessageActionAdapter, type ChannelPlugin, @@ -32,6 +28,7 @@ import { } from "openclaw/plugin-sdk/signal"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { getSignalRuntime } from "./runtime.js"; +import { signalSetupAdapter, signalSetupWizard } from "./setup-surface.js"; const signalMessageActions: ChannelMessageActionAdapter = { listActions: (ctx) => getSignalRuntime().channel.signal.messageActions?.listActions?.(ctx) ?? [], @@ -46,8 +43,6 @@ const signalMessageActions: ChannelMessageActionAdapter = { }, }; -const meta = getChatChannelMeta("signal"); - const signalConfigAccessors = createScopedAccountConfigAccessors({ resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }), resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom, @@ -60,22 +55,6 @@ const signalConfigAccessors = createScopedAccountConfigAccessors({ resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo, }); -function buildSignalSetupPatch(input: { - signalNumber?: string; - cliPath?: string; - httpUrl?: string; - httpHost?: string; - httpPort?: string; -}) { - return { - ...(input.signalNumber ? { account: input.signalNumber } : {}), - ...(input.cliPath ? { cliPath: input.cliPath } : {}), - ...(input.httpUrl ? { httpUrl: input.httpUrl } : {}), - ...(input.httpHost ? { httpHost: input.httpHost } : {}), - ...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}), - }; -} - type SignalSendFn = ReturnType["channel"]["signal"]["sendMessageSignal"]; async function sendSignalOutbound(params: { @@ -108,9 +87,9 @@ async function sendSignalOutbound(params: { export const signalPlugin: ChannelPlugin = { id: "signal", meta: { - ...meta, + ...getChatChannelMeta("signal"), }, - onboarding: signalOnboardingAdapter, + setupWizard: signalSetupWizard, pairing: { idLabel: "signalNumber", normalizeAllowEntry: (entry) => entry.replace(/^signal:/i, ""), @@ -191,74 +170,7 @@ export const signalPlugin: ChannelPlugin = { hint: "", }, }, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: "signal", - accountId, - name, - }), - validateInput: ({ input }) => { - if ( - !input.signalNumber && - !input.httpUrl && - !input.httpHost && - !input.httpPort && - !input.cliPath - ) { - return "Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: "signal", - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "signal", - }) - : 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), - }, - }, - }, - }, - }; - }, - }, + setup: signalSetupAdapter, outbound: { deliveryMode: "direct", chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit), diff --git a/extensions/signal/src/onboarding.ts b/extensions/signal/src/onboarding.ts deleted file mode 100644 index 7279ea1977a..00000000000 --- a/extensions/signal/src/onboarding.ts +++ /dev/null @@ -1,254 +0,0 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../../../src/channels/plugins/onboarding-types.js"; -import { - parseOnboardingEntriesAllowingWildcard, - patchChannelConfigForAccount, - promptParsedAllowFromForScopedChannel, - resolveAccountIdForConfigure, - setChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; -import { formatCliCommand } from "../../../src/cli/command-format.js"; -import { detectBinary } from "../../../src/commands/onboard-helpers.js"; -import { installSignalCli } from "../../../src/commands/signal-install.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; -import { normalizeE164 } from "../../../src/utils.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; -import { - listSignalAccountIds, - resolveDefaultSignalAccountId, - resolveSignalAccount, -} from "./accounts.js"; - -const channel = "signal" as const; -const MIN_E164_DIGITS = 5; -const MAX_E164_DIGITS = 15; -const DIGITS_ONLY = /^\d+$/; -const INVALID_SIGNAL_ACCOUNT_ERROR = - "Invalid E.164 phone number (must start with + and country code, e.g. +15555550123)"; - -export function normalizeSignalAccountInput(value: string | null | undefined): string | null { - const trimmed = value?.trim(); - if (!trimmed) { - return null; - } - const normalized = normalizeE164(trimmed); - const digits = normalized.slice(1); - if (!DIGITS_ONLY.test(digits)) { - return null; - } - if (digits.length < MIN_E164_DIGITS || digits.length > MAX_E164_DIGITS) { - return null; - } - return `+${digits}`; -} - -function isUuidLike(value: string): boolean { - return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); -} - -export function parseSignalAllowFromEntries(raw: string): { entries: string[]; error?: string } { - return parseOnboardingEntriesAllowingWildcard(raw, (entry) => { - if (entry.toLowerCase().startsWith("uuid:")) { - const id = entry.slice("uuid:".length).trim(); - if (!id) { - return { error: "Invalid uuid entry" }; - } - return { value: `uuid:${id}` }; - } - if (isUuidLike(entry)) { - return { value: `uuid:${entry}` }; - } - const normalized = normalizeSignalAccountInput(entry); - if (!normalized) { - return { error: `Invalid entry: ${entry}` }; - } - return { value: normalized }; - }); -} - -async function promptSignalAllowFrom(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - return promptParsedAllowFromForScopedChannel({ - cfg: params.cfg, - channel: "signal", - accountId: params.accountId, - defaultAccountId: resolveDefaultSignalAccountId(params.cfg), - prompter: params.prompter, - noteTitle: "Signal allowlist", - noteLines: [ - "Allowlist Signal DMs by sender id.", - "Examples:", - "- +15555550123", - "- uuid:123e4567-e89b-12d3-a456-426614174000", - "Multiple entries: comma-separated.", - `Docs: ${formatDocsLink("/signal", "signal")}`, - ], - message: "Signal allowFrom (E.164 or uuid)", - placeholder: "+15555550123, uuid:123e4567-e89b-12d3-a456-426614174000", - parseEntries: parseSignalAllowFromEntries, - getExistingAllowFrom: ({ cfg, accountId }) => { - const resolved = resolveSignalAccount({ cfg, accountId }); - return resolved.config.allowFrom ?? []; - }, - }); -} - -const dmPolicy: ChannelOnboardingDmPolicy = { - label: "Signal", - channel, - policyKey: "channels.signal.dmPolicy", - allowFromKey: "channels.signal.allowFrom", - getCurrent: (cfg) => cfg.channels?.signal?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => - setChannelDmPolicyWithAllowFrom({ - cfg, - channel: "signal", - dmPolicy: policy, - }), - promptAllowFrom: promptSignalAllowFrom, -}; - -export const signalOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const configured = listSignalAccountIds(cfg).some( - (accountId) => resolveSignalAccount({ cfg, accountId }).configured, - ); - const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; - const signalCliDetected = await detectBinary(signalCliPath); - return { - channel, - configured, - statusLines: [ - `Signal: ${configured ? "configured" : "needs setup"}`, - `signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`, - ], - selectionHint: signalCliDetected ? "signal-cli found" : "signal-cli missing", - quickstartScore: signalCliDetected ? 1 : 0, - }; - }, - configure: async ({ - cfg, - runtime, - prompter, - accountOverrides, - shouldPromptAccountIds, - options, - }) => { - const defaultSignalAccountId = resolveDefaultSignalAccountId(cfg); - const signalAccountId = await resolveAccountIdForConfigure({ - cfg, - prompter, - label: "Signal", - accountOverride: accountOverrides.signal, - shouldPromptAccountIds, - listAccountIds: listSignalAccountIds, - defaultAccountId: defaultSignalAccountId, - }); - - let next = cfg; - const resolvedAccount = resolveSignalAccount({ - cfg: next, - accountId: signalAccountId, - }); - const accountConfig = resolvedAccount.config; - let resolvedCliPath = accountConfig.cliPath ?? "signal-cli"; - let cliDetected = await detectBinary(resolvedCliPath); - if (options?.allowSignalInstall) { - const wantsInstall = await prompter.confirm({ - message: cliDetected - ? "signal-cli detected. Reinstall/update now?" - : "signal-cli not found. Install now?", - initialValue: !cliDetected, - }); - if (wantsInstall) { - try { - const result = await installSignalCli(runtime); - if (result.ok && result.cliPath) { - cliDetected = true; - resolvedCliPath = result.cliPath; - await prompter.note(`Installed signal-cli at ${result.cliPath}`, "Signal"); - } else if (!result.ok) { - await prompter.note(result.error ?? "signal-cli install failed.", "Signal"); - } - } catch (err) { - await prompter.note(`signal-cli install failed: ${String(err)}`, "Signal"); - } - } - } - - if (!cliDetected) { - await prompter.note( - "signal-cli not found. Install it, then rerun this step or set channels.signal.cliPath.", - "Signal", - ); - } - - let account = accountConfig.account ?? ""; - if (account) { - const normalizedExisting = normalizeSignalAccountInput(account); - if (!normalizedExisting) { - await prompter.note( - "Existing Signal account isn't a valid E.164 number. Please enter it again.", - "Signal", - ); - account = ""; - } else { - account = normalizedExisting; - const keep = await prompter.confirm({ - message: `Signal account set (${account}). Keep it?`, - initialValue: true, - }); - if (!keep) { - account = ""; - } - } - } - - if (!account) { - const rawAccount = String( - await prompter.text({ - message: "Signal bot number (E.164)", - validate: (value) => - normalizeSignalAccountInput(String(value ?? "")) - ? undefined - : INVALID_SIGNAL_ACCOUNT_ERROR, - }), - ); - account = normalizeSignalAccountInput(rawAccount) ?? ""; - } - - if (account) { - next = patchChannelConfigForAccount({ - cfg: next, - channel: "signal", - accountId: signalAccountId, - patch: { - account, - cliPath: resolvedCliPath ?? "signal-cli", - }, - }); - } - - await prompter.note( - [ - 'Link device with: signal-cli link -n "OpenClaw"', - "Scan QR in Signal → Linked Devices", - `Then run: ${formatCliCommand("openclaw gateway call channels.status --params '{\"probe\":true}'")}`, - `Docs: ${formatDocsLink("/signal", "signal")}`, - ].join("\n"), - "Signal next steps", - ); - - return { cfg: next, accountId: signalAccountId }; - }, - dmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), -}; diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts new file mode 100644 index 00000000000..6a7b7604450 --- /dev/null +++ b/extensions/signal/src/setup-surface.ts @@ -0,0 +1,312 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + parseOnboardingEntriesAllowingWildcard, + promptParsedAllowFromForScopedChannel, + setChannelDmPolicyWithAllowFrom, + setOnboardingChannelEnabled, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { formatCliCommand } from "../../../src/cli/command-format.js"; +import { detectBinary } from "../../../src/commands/onboard-helpers.js"; +import { installSignalCli } from "../../../src/commands/signal-install.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { normalizeE164 } from "../../../src/utils.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { + listSignalAccountIds, + resolveDefaultSignalAccountId, + resolveSignalAccount, +} from "./accounts.js"; + +const channel = "signal" as const; +const MIN_E164_DIGITS = 5; +const MAX_E164_DIGITS = 15; +const DIGITS_ONLY = /^\d+$/; +const INVALID_SIGNAL_ACCOUNT_ERROR = + "Invalid E.164 phone number (must start with + and country code, e.g. +15555550123)"; + +export function normalizeSignalAccountInput(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + const normalized = normalizeE164(trimmed); + const digits = normalized.slice(1); + if (!DIGITS_ONLY.test(digits)) { + return null; + } + if (digits.length < MIN_E164_DIGITS || digits.length > MAX_E164_DIGITS) { + return null; + } + return `+${digits}`; +} + +function isUuidLike(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); +} + +export function parseSignalAllowFromEntries(raw: string): { entries: string[]; error?: string } { + return parseOnboardingEntriesAllowingWildcard(raw, (entry) => { + if (entry.toLowerCase().startsWith("uuid:")) { + const id = entry.slice("uuid:".length).trim(); + if (!id) { + return { error: "Invalid uuid entry" }; + } + return { value: `uuid:${id}` }; + } + if (isUuidLike(entry)) { + return { value: `uuid:${entry}` }; + } + const normalized = normalizeSignalAccountInput(entry); + if (!normalized) { + return { error: `Invalid entry: ${entry}` }; + } + return { value: normalized }; + }); +} + +function buildSignalSetupPatch(input: { + signalNumber?: string; + cliPath?: string; + httpUrl?: string; + httpHost?: string; + httpPort?: string; +}) { + return { + ...(input.signalNumber ? { account: input.signalNumber } : {}), + ...(input.cliPath ? { cliPath: input.cliPath } : {}), + ...(input.httpUrl ? { httpUrl: input.httpUrl } : {}), + ...(input.httpHost ? { httpHost: input.httpHost } : {}), + ...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}), + }; +} + +async function promptSignalAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + return promptParsedAllowFromForScopedChannel({ + cfg: params.cfg, + channel, + accountId: params.accountId, + defaultAccountId: resolveDefaultSignalAccountId(params.cfg), + prompter: params.prompter, + noteTitle: "Signal allowlist", + noteLines: [ + "Allowlist Signal DMs by sender id.", + "Examples:", + "- +15555550123", + "- uuid:123e4567-e89b-12d3-a456-426614174000", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/signal", "signal")}`, + ], + message: "Signal allowFrom (E.164 or uuid)", + placeholder: "+15555550123, uuid:123e4567-e89b-12d3-a456-426614174000", + parseEntries: parseSignalAllowFromEntries, + getExistingAllowFrom: ({ cfg, accountId }) => + resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? [], + }); +} + +const signalDmPolicy: ChannelOnboardingDmPolicy = { + label: "Signal", + channel, + policyKey: "channels.signal.dmPolicy", + allowFromKey: "channels.signal.allowFrom", + getCurrent: (cfg) => cfg.channels?.signal?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => + setChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), + promptAllowFrom: promptSignalAllowFrom, +}; + +export const signalSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ input }) => { + if ( + !input.signalNumber && + !input.httpUrl && + !input.httpHost && + !input.httpPort && + !input.cliPath + ) { + return "Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + signal: { + ...next.channels?.signal, + enabled: true, + ...buildSignalSetupPatch(input), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + signal: { + ...next.channels?.signal, + enabled: true, + accounts: { + ...next.channels?.signal?.accounts, + [accountId]: { + ...next.channels?.signal?.accounts?.[accountId], + enabled: true, + ...buildSignalSetupPatch(input), + }, + }, + }, + }, + }; + }, +}; + +export const signalSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + configuredHint: "signal-cli found", + unconfiguredHint: "signal-cli missing", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => + listSignalAccountIds(cfg).some( + (accountId) => resolveSignalAccount({ cfg, accountId }).configured, + ), + resolveStatusLines: async ({ cfg, configured }) => { + const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; + const signalCliDetected = await detectBinary(signalCliPath); + return [ + `Signal: ${configured ? "configured" : "needs setup"}`, + `signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`, + ]; + }, + resolveSelectionHint: async ({ cfg }) => { + const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; + return (await detectBinary(signalCliPath)) ? "signal-cli found" : "signal-cli missing"; + }, + resolveQuickstartScore: async ({ cfg }) => { + const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; + return (await detectBinary(signalCliPath)) ? 1 : 0; + }, + }, + prepare: async ({ cfg, accountId, credentialValues, runtime, prompter, options }) => { + if (!options?.allowSignalInstall) { + return; + } + const currentCliPath = + (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? + resolveSignalAccount({ cfg, accountId }).config.cliPath ?? + "signal-cli"; + const cliDetected = await detectBinary(currentCliPath); + const wantsInstall = await prompter.confirm({ + message: cliDetected + ? "signal-cli detected. Reinstall/update now?" + : "signal-cli not found. Install now?", + initialValue: !cliDetected, + }); + if (!wantsInstall) { + return; + } + try { + const result = await installSignalCli(runtime); + if (result.ok && result.cliPath) { + await prompter.note(`Installed signal-cli at ${result.cliPath}`, "Signal"); + return { + credentialValues: { + cliPath: result.cliPath, + }, + }; + } + if (!result.ok) { + await prompter.note(result.error ?? "signal-cli install failed.", "Signal"); + } + } catch (error) { + await prompter.note(`signal-cli install failed: ${String(error)}`, "Signal"); + } + }, + credentials: [], + textInputs: [ + { + inputKey: "cliPath", + message: "signal-cli path", + currentValue: ({ cfg, accountId, credentialValues }) => + (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? + resolveSignalAccount({ cfg, accountId }).config.cliPath ?? + "signal-cli", + initialValue: ({ cfg, accountId, credentialValues }) => + (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? + resolveSignalAccount({ cfg, accountId }).config.cliPath ?? + "signal-cli", + shouldPrompt: async ({ currentValue }) => !(await detectBinary(currentValue ?? "signal-cli")), + confirmCurrentValue: false, + applyCurrentValue: true, + helpTitle: "Signal", + helpLines: [ + "signal-cli not found. Install it, then rerun this step or set channels.signal.cliPath.", + ], + }, + { + inputKey: "signalNumber", + message: "Signal bot number (E.164)", + currentValue: ({ cfg, accountId }) => + normalizeSignalAccountInput(resolveSignalAccount({ cfg, accountId }).config.account) ?? + undefined, + keepPrompt: (value) => `Signal account set (${value}). Keep it?`, + validate: ({ value }) => + normalizeSignalAccountInput(value) ? undefined : INVALID_SIGNAL_ACCOUNT_ERROR, + normalizeValue: ({ value }) => normalizeSignalAccountInput(value) ?? value, + }, + ], + completionNote: { + title: "Signal next steps", + lines: [ + 'Link device with: signal-cli link -n "OpenClaw"', + "Scan QR in Signal -> Linked Devices", + `Then run: ${formatCliCommand("openclaw gateway call channels.status --params '{\"probe\":true}'")}`, + `Docs: ${formatDocsLink("/signal", "signal")}`, + ], + }, + dmPolicy: signalDmPolicy, + disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), +}; diff --git a/src/channels/plugins/onboarding/imessage.test.ts b/src/channels/plugins/onboarding/imessage.test.ts index 6825cdc67e0..4fa8f277d21 100644 --- a/src/channels/plugins/onboarding/imessage.test.ts +++ b/src/channels/plugins/onboarding/imessage.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseIMessageAllowFromEntries } from "../../../../extensions/imessage/src/onboarding.js"; +import { parseIMessageAllowFromEntries } from "../../../../extensions/imessage/src/setup-surface.js"; describe("parseIMessageAllowFromEntries", () => { it("parses handles and chat targets", () => { diff --git a/src/channels/plugins/onboarding/signal.test.ts b/src/channels/plugins/onboarding/signal.test.ts index e0b83003db7..61656952489 100644 --- a/src/channels/plugins/onboarding/signal.test.ts +++ b/src/channels/plugins/onboarding/signal.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { normalizeSignalAccountInput, parseSignalAllowFromEntries, -} from "../../../../extensions/signal/src/onboarding.js"; +} from "../../../../extensions/signal/src/setup-surface.js"; describe("normalizeSignalAccountInput", () => { it("normalizes valid E.164 numbers", () => { diff --git a/src/channels/plugins/plugins-channel.test.ts b/src/channels/plugins/plugins-channel.test.ts index 76452137682..01a9d29169a 100644 --- a/src/channels/plugins/plugins-channel.test.ts +++ b/src/channels/plugins/plugins-channel.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { normalizeSignalAccountInput } from "../../../extensions/signal/src/onboarding.js"; +import { normalizeSignalAccountInput } from "../../../extensions/signal/src/setup-surface.js"; import type { OpenClawConfig } from "../../config/config.js"; import { normalizeIMessageMessagingTarget } from "./normalize/imessage.js"; import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize/signal.js"; diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index 1e231babc58..8c8727ef5d9 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -24,7 +24,10 @@ export { resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { imessageOnboardingAdapter } from "../../extensions/imessage/src/onboarding.js"; +export { + imessageSetupAdapter, + imessageSetupWizard, +} from "../../extensions/imessage/src/setup-surface.js"; export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index dc59602b7c2..5c8c514d191 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -704,7 +704,10 @@ export { resolveIMessageAccount, type ResolvedIMessageAccount, } from "../../extensions/imessage/src/accounts.js"; -export { imessageOnboardingAdapter } from "../../extensions/imessage/src/onboarding.js"; +export { + imessageSetupAdapter, + imessageSetupWizard, +} from "../../extensions/imessage/src/setup-surface.js"; export { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, @@ -776,7 +779,10 @@ export { resolveSignalAccount, type ResolvedSignalAccount, } from "../../extensions/signal/src/accounts.js"; -export { signalOnboardingAdapter } from "../../extensions/signal/src/onboarding.js"; +export { + signalSetupAdapter, + signalSetupWizard, +} from "../../extensions/signal/src/setup-surface.js"; export { looksLikeSignalTargetId, normalizeSignalMessagingTarget, diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index 7a44633b8e6..2eb0497c277 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -16,7 +16,10 @@ export { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, } from "../config/runtime-group-policy.js"; -export { signalOnboardingAdapter } from "../../extensions/signal/src/onboarding.js"; +export { + signalSetupAdapter, + signalSetupWizard, +} from "../../extensions/signal/src/setup-surface.js"; export { SignalConfigSchema } from "../config/zod-schema.providers-core.js"; export { normalizeE164 } from "../utils.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index d005a2af1f1..8068f342b0e 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -80,12 +80,14 @@ describe("plugin-sdk subpath exports", () => { it("exports Signal helpers", () => { expect(typeof signalSdk.resolveSignalAccount).toBe("function"); - expect(typeof signalSdk.signalOnboardingAdapter).toBe("object"); + expect(typeof signalSdk.signalSetupWizard).toBe("object"); + expect(typeof signalSdk.signalSetupAdapter).toBe("object"); }); it("exports iMessage helpers", () => { expect(typeof imessageSdk.resolveIMessageAccount).toBe("function"); - expect(typeof imessageSdk.imessageOnboardingAdapter).toBe("object"); + expect(typeof imessageSdk.imessageSetupWizard).toBe("object"); + expect(typeof imessageSdk.imessageSetupAdapter).toBe("object"); }); it("exports WhatsApp helpers", () => { From e42d86afa9ea027dad9879ccb36999b7999751ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 17:06:31 -0700 Subject: [PATCH 058/943] docs: document richer setup wizard prompts --- docs/tools/plugin.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 983c69f0a12..e29c50e2948 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1428,10 +1428,13 @@ Wizard precedence: `plugin.setupWizard` is best for channels that fit the shared pattern: - one account picker driven by `plugin.config.listAccountIds` +- optional preflight/prepare step before prompting (for example installer/bootstrap work) - optional env-shortcut prompt for bundled credential sets (for example paired bot/app tokens) - one or more credential prompts, with each step either writing through `plugin.setup.applyAccountConfig` or a channel-owned partial patch +- optional non-secret text prompts (for example CLI paths, base URLs, account ids) - optional channel/group access allowlist prompts resolved by the host - optional DM allowlist resolution (for example `@username` -> numeric id) +- optional completion note after setup finishes `plugin.onboarding` hooks still return the same values as before: From ee7ecb2dd42eb957e70f78aad50200335ed800fe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 17:07:28 -0700 Subject: [PATCH 059/943] feat(plugins): move anthropic and openai vendors to plugins --- .github/labeler.yml | 8 ++ docs/concepts/model-providers.md | 4 + docs/tools/plugin.md | 9 ++ extensions/anthropic/index.test.ts | 102 +++++++++++++++ extensions/anthropic/index.ts | 124 +++++++++++++++++++ extensions/anthropic/openclaw.plugin.json | 9 ++ extensions/anthropic/package.json | 12 ++ extensions/openai/index.test.ts | 76 ++++++++++++ extensions/openai/index.ts | 137 +++++++++++++++++++++ extensions/openai/openclaw.plugin.json | 9 ++ extensions/openai/package.json | 12 ++ src/agents/pi-embedded-runner/cache-ttl.ts | 7 +- src/agents/pi-embedded-runner/model.ts | 2 +- src/agents/provider-capabilities.test.ts | 11 +- src/agents/provider-capabilities.ts | 14 +-- src/plugins/config-state.ts | 2 + src/plugins/providers.ts | 2 + 17 files changed, 530 insertions(+), 10 deletions(-) create mode 100644 extensions/anthropic/index.test.ts create mode 100644 extensions/anthropic/index.ts create mode 100644 extensions/anthropic/openclaw.plugin.json create mode 100644 extensions/anthropic/package.json create mode 100644 extensions/openai/index.test.ts create mode 100644 extensions/openai/index.ts create mode 100644 extensions/openai/openclaw.plugin.json create mode 100644 extensions/openai/package.json diff --git a/.github/labeler.yml b/.github/labeler.yml index 08ede2a1ca5..d980a8d096e 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -242,6 +242,10 @@ - changed-files: - any-glob-to-any-file: - "extensions/byteplus/**" +"extensions: anthropic": + - changed-files: + - any-glob-to-any-file: + - "extensions/anthropic/**" "extensions: cloudflare-ai-gateway": - changed-files: - any-glob-to-any-file: @@ -258,6 +262,10 @@ - changed-files: - any-glob-to-any-file: - "extensions/kilocode/**" +"extensions: openai": + - changed-files: + - any-glob-to-any-file: + - "extensions/openai/**" "extensions: kimi-coding": - changed-files: - any-glob-to-any-file: diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 7a5ef04ab11..3a29c373c1d 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -51,10 +51,14 @@ Typical split: Current bundled examples: +- `anthropic`: Claude 4.6 forward-compat fallback, usage endpoint fetching, + and cache-TTL/provider-family metadata - `openrouter`: pass-through model ids, request wrappers, provider capability hints, and cache-TTL policy - `github-copilot`: forward-compat model fallback, Claude-thinking transcript hints, runtime token exchange, and usage endpoint fetching +- `openai`: GPT-5.4 forward-compat fallback, direct OpenAI transport + normalization, and provider-family metadata - `openai-codex`: forward-compat model fallback, transport normalization, and default transport params plus usage endpoint fetching - `google-gemini-cli`: Gemini 3.1 forward-compat fallback plus usage-token diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index e29c50e2948..8aa7beefa42 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -164,6 +164,7 @@ Important trust note: - [Nostr](/channels/nostr) — `@openclaw/nostr` - [Zalo](/channels/zalo) — `@openclaw/zalo` - [Microsoft Teams](/channels/msteams) — `@openclaw/msteams` +- Anthropic provider runtime — bundled as `anthropic` (enabled by default) - BytePlus provider catalog — bundled as `byteplus` (enabled by default) - Cloudflare AI Gateway provider catalog — bundled as `cloudflare-ai-gateway` (enabled by default) - Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default) @@ -178,6 +179,7 @@ Important trust note: - Model Studio provider catalog — bundled as `modelstudio` (enabled by default) - Moonshot provider runtime — bundled as `moonshot` (enabled by default) - NVIDIA provider catalog — bundled as `nvidia` (enabled by default) +- OpenAI provider runtime — bundled as `openai` (enabled by default) - OpenAI Codex provider runtime — bundled as `openai-codex` (enabled by default) - OpenCode Go provider capabilities — bundled as `opencode-go` (enabled by default) - OpenCode Zen provider capabilities — bundled as `opencode` (enabled by default) @@ -348,6 +350,13 @@ api.registerProvider({ ### Built-in examples +- Anthropic uses `resolveDynamicModel`, `capabilities`, `resolveUsageAuth`, + `fetchUsageSnapshot`, and `isCacheTtlEligible` because it owns Claude 4.6 + forward-compat, provider-family hints, usage endpoint integration, and + prompt-cache eligibility. +- OpenAI uses `resolveDynamicModel`, `normalizeResolvedModel`, and + `capabilities` because it owns GPT-5.4 forward-compat plus the direct OpenAI + `openai-completions` -> `openai-responses` normalization. - OpenRouter uses `catalog` plus `resolveDynamicModel` and `prepareDynamicModel` because the provider is pass-through and may expose new model ids before OpenClaw's static catalog updates. diff --git a/extensions/anthropic/index.test.ts b/extensions/anthropic/index.test.ts new file mode 100644 index 00000000000..00fe6ba74ee --- /dev/null +++ b/extensions/anthropic/index.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import type { ProviderPlugin } from "../../src/plugins/types.js"; +import { + createProviderUsageFetch, + makeResponse, +} from "../../src/test-utils/provider-usage-fetch.js"; +import anthropicPlugin from "./index.js"; + +function registerProvider(): ProviderPlugin { + let provider: ProviderPlugin | undefined; + anthropicPlugin.register({ + registerProvider(nextProvider: ProviderPlugin) { + provider = nextProvider; + }, + } as never); + if (!provider) { + throw new Error("provider registration missing"); + } + return provider; +} + +describe("anthropic plugin", () => { + it("owns anthropic 4.6 forward-compat resolution", () => { + const provider = registerProvider(); + const model = provider.resolveDynamicModel?.({ + provider: "anthropic", + modelId: "claude-sonnet-4.6-20260219", + modelRegistry: { + find: (_provider: string, id: string) => + id === "claude-sonnet-4.5-20260219" + ? { + id, + name: id, + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + } + : null, + } as never, + }); + + expect(model).toMatchObject({ + id: "claude-sonnet-4.6-20260219", + provider: "anthropic", + api: "anthropic-messages", + baseUrl: "https://api.anthropic.com", + }); + }); + + it("owns usage auth resolution", async () => { + const provider = registerProvider(); + await expect( + provider.resolveUsageAuth?.({ + config: {} as never, + env: {} as NodeJS.ProcessEnv, + provider: "anthropic", + resolveApiKeyFromConfigAndStore: () => undefined, + resolveOAuthToken: async () => ({ + token: "anthropic-oauth-token", + }), + }), + ).resolves.toEqual({ + token: "anthropic-oauth-token", + }); + }); + + it("owns usage snapshot fetching", async () => { + const provider = registerProvider(); + const mockFetch = createProviderUsageFetch(async (url) => { + if (url.includes("api.anthropic.com/api/oauth/usage")) { + return makeResponse(200, { + five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" }, + seven_day: { utilization: 35, resets_at: "2026-01-09T01:00:00Z" }, + }); + } + return makeResponse(404, "not found"); + }); + + const snapshot = await provider.fetchUsageSnapshot?.({ + config: {} as never, + env: {} as NodeJS.ProcessEnv, + provider: "anthropic", + token: "anthropic-oauth-token", + timeoutMs: 5_000, + fetchFn: mockFetch as unknown as typeof fetch, + }); + + expect(snapshot).toEqual({ + provider: "anthropic", + displayName: "Claude", + windows: [ + { label: "5h", usedPercent: 20, resetAt: Date.parse("2026-01-07T01:00:00Z") }, + { label: "Week", usedPercent: 35, resetAt: Date.parse("2026-01-09T01:00:00Z") }, + ], + }); + }); +}); diff --git a/extensions/anthropic/index.ts b/extensions/anthropic/index.ts new file mode 100644 index 00000000000..bb17f9d4dc1 --- /dev/null +++ b/extensions/anthropic/index.ts @@ -0,0 +1,124 @@ +import { + emptyPluginConfigSchema, + type OpenClawPluginApi, + type ProviderResolveDynamicModelContext, + type ProviderRuntimeModel, +} from "openclaw/plugin-sdk/core"; +import { normalizeModelCompat } from "../../src/agents/model-compat.js"; +import { fetchClaudeUsage } from "../../src/infra/provider-usage.fetch.js"; + +const PROVIDER_ID = "anthropic"; +const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6"; +const ANTHROPIC_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6"; +const ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] as const; +const ANTHROPIC_SONNET_46_MODEL_ID = "claude-sonnet-4-6"; +const ANTHROPIC_SONNET_46_DOT_MODEL_ID = "claude-sonnet-4.6"; +const ANTHROPIC_SONNET_TEMPLATE_MODEL_IDS = ["claude-sonnet-4-5", "claude-sonnet-4.5"] as const; + +function cloneFirstTemplateModel(params: { + modelId: string; + templateIds: readonly string[]; + ctx: ProviderResolveDynamicModelContext; +}): ProviderRuntimeModel | undefined { + const trimmedModelId = params.modelId.trim(); + for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { + const template = params.ctx.modelRegistry.find( + PROVIDER_ID, + templateId, + ) as ProviderRuntimeModel | null; + if (!template) { + continue; + } + return normalizeModelCompat({ + ...template, + id: trimmedModelId, + name: trimmedModelId, + } as ProviderRuntimeModel); + } + return undefined; +} + +function resolveAnthropic46ForwardCompatModel(params: { + ctx: ProviderResolveDynamicModelContext; + dashModelId: string; + dotModelId: string; + dashTemplateId: string; + dotTemplateId: string; + fallbackTemplateIds: readonly string[]; +}): ProviderRuntimeModel | undefined { + const trimmedModelId = params.ctx.modelId.trim(); + const lower = trimmedModelId.toLowerCase(); + const is46Model = + lower === params.dashModelId || + lower === params.dotModelId || + lower.startsWith(`${params.dashModelId}-`) || + lower.startsWith(`${params.dotModelId}-`); + if (!is46Model) { + return undefined; + } + + const templateIds: string[] = []; + if (lower.startsWith(params.dashModelId)) { + templateIds.push(lower.replace(params.dashModelId, params.dashTemplateId)); + } + if (lower.startsWith(params.dotModelId)) { + templateIds.push(lower.replace(params.dotModelId, params.dotTemplateId)); + } + templateIds.push(...params.fallbackTemplateIds); + + return cloneFirstTemplateModel({ + modelId: trimmedModelId, + templateIds, + ctx: params.ctx, + }); +} + +function resolveAnthropicForwardCompatModel( + ctx: ProviderResolveDynamicModelContext, +): ProviderRuntimeModel | undefined { + return ( + resolveAnthropic46ForwardCompatModel({ + ctx, + dashModelId: ANTHROPIC_OPUS_46_MODEL_ID, + dotModelId: ANTHROPIC_OPUS_46_DOT_MODEL_ID, + dashTemplateId: "claude-opus-4-5", + dotTemplateId: "claude-opus-4.5", + fallbackTemplateIds: ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS, + }) ?? + resolveAnthropic46ForwardCompatModel({ + ctx, + dashModelId: ANTHROPIC_SONNET_46_MODEL_ID, + dotModelId: ANTHROPIC_SONNET_46_DOT_MODEL_ID, + dashTemplateId: "claude-sonnet-4-5", + dotTemplateId: "claude-sonnet-4.5", + fallbackTemplateIds: ANTHROPIC_SONNET_TEMPLATE_MODEL_IDS, + }) + ); +} + +const anthropicPlugin = { + id: PROVIDER_ID, + name: "Anthropic Provider", + description: "Bundled Anthropic provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "Anthropic", + docsPath: "/providers/models", + envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], + auth: [], + resolveDynamicModel: (ctx) => resolveAnthropicForwardCompatModel(ctx), + capabilities: { + providerFamily: "anthropic", + dropThinkingBlockModelHints: ["claude"], + }, + resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(), + fetchUsageSnapshot: async (ctx) => + await fetchClaudeUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), + isCacheTtlEligible: () => true, + }); + }, +}; + +export default anthropicPlugin; diff --git a/extensions/anthropic/openclaw.plugin.json b/extensions/anthropic/openclaw.plugin.json new file mode 100644 index 00000000000..5342e849e52 --- /dev/null +++ b/extensions/anthropic/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "anthropic", + "providers": ["anthropic"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/anthropic/package.json b/extensions/anthropic/package.json new file mode 100644 index 00000000000..7d06af1c26d --- /dev/null +++ b/extensions/anthropic/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/anthropic-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Anthropic provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/openai/index.test.ts b/extensions/openai/index.test.ts new file mode 100644 index 00000000000..cdf2d1f8a27 --- /dev/null +++ b/extensions/openai/index.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import type { ProviderPlugin } from "../../src/plugins/types.js"; +import openAIPlugin from "./index.js"; + +function registerProvider(): ProviderPlugin { + let provider: ProviderPlugin | undefined; + openAIPlugin.register({ + registerProvider(nextProvider: ProviderPlugin) { + provider = nextProvider; + }, + } as never); + if (!provider) { + throw new Error("provider registration missing"); + } + return provider; +} + +describe("openai plugin", () => { + it("owns openai gpt-5.4 forward-compat resolution", () => { + const provider = registerProvider(); + const model = provider.resolveDynamicModel?.({ + provider: "openai", + modelId: "gpt-5.4-pro", + modelRegistry: { + find: (_provider: string, id: string) => + id === "gpt-5.2-pro" + ? { + id, + name: id, + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + } + : null, + } as never, + }); + + expect(model).toMatchObject({ + id: "gpt-5.4-pro", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + contextWindow: 1_050_000, + maxTokens: 128_000, + }); + }); + + it("owns direct openai transport normalization", () => { + const provider = registerProvider(); + expect( + provider.normalizeResolvedModel?.({ + provider: "openai", + modelId: "gpt-5.4", + model: { + id: "gpt-5.4", + name: "gpt-5.4", + api: "openai-completions", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_050_000, + maxTokens: 128_000, + }, + }), + ).toMatchObject({ + api: "openai-responses", + }); + }); +}); diff --git a/extensions/openai/index.ts b/extensions/openai/index.ts new file mode 100644 index 00000000000..cc2ca6fe4a0 --- /dev/null +++ b/extensions/openai/index.ts @@ -0,0 +1,137 @@ +import { + emptyPluginConfigSchema, + type OpenClawPluginApi, + type ProviderResolveDynamicModelContext, + type ProviderRuntimeModel, +} from "openclaw/plugin-sdk/core"; +import { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.js"; +import { normalizeModelCompat } from "../../src/agents/model-compat.js"; +import { normalizeProviderId } from "../../src/agents/model-selection.js"; + +const PROVIDER_ID = "openai"; +const OPENAI_BASE_URL = "https://api.openai.com/v1"; +const OPENAI_GPT_54_MODEL_ID = "gpt-5.4"; +const OPENAI_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro"; +const OPENAI_GPT_54_CONTEXT_TOKENS = 1_050_000; +const OPENAI_GPT_54_MAX_TOKENS = 128_000; +const OPENAI_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.2"] as const; +const OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS = ["gpt-5.2-pro", "gpt-5.2"] as const; + +function isOpenAIApiBaseUrl(baseUrl?: string): boolean { + const trimmed = baseUrl?.trim(); + if (!trimmed) { + return false; + } + return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/i.test(trimmed); +} + +function normalizeOpenAITransport(model: ProviderRuntimeModel): ProviderRuntimeModel { + const useResponsesTransport = + model.api === "openai-completions" && (!model.baseUrl || isOpenAIApiBaseUrl(model.baseUrl)); + + if (!useResponsesTransport) { + return model; + } + + return { + ...model, + api: "openai-responses", + }; +} + +function cloneFirstTemplateModel(params: { + modelId: string; + templateIds: readonly string[]; + ctx: ProviderResolveDynamicModelContext; + patch?: Partial; +}): ProviderRuntimeModel | undefined { + const trimmedModelId = params.modelId.trim(); + for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { + const template = params.ctx.modelRegistry.find( + PROVIDER_ID, + templateId, + ) as ProviderRuntimeModel | null; + if (!template) { + continue; + } + return normalizeModelCompat({ + ...template, + id: trimmedModelId, + name: trimmedModelId, + ...params.patch, + } as ProviderRuntimeModel); + } + return undefined; +} + +function resolveOpenAIGpt54ForwardCompatModel( + ctx: ProviderResolveDynamicModelContext, +): ProviderRuntimeModel | undefined { + const trimmedModelId = ctx.modelId.trim(); + const lower = trimmedModelId.toLowerCase(); + let templateIds: readonly string[]; + if (lower === OPENAI_GPT_54_MODEL_ID) { + templateIds = OPENAI_GPT_54_TEMPLATE_MODEL_IDS; + } else if (lower === OPENAI_GPT_54_PRO_MODEL_ID) { + templateIds = OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS; + } else { + return undefined; + } + + return ( + cloneFirstTemplateModel({ + modelId: trimmedModelId, + templateIds, + ctx, + patch: { + api: "openai-responses", + provider: PROVIDER_ID, + baseUrl: OPENAI_BASE_URL, + reasoning: true, + input: ["text", "image"], + contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, + maxTokens: OPENAI_GPT_54_MAX_TOKENS, + }, + }) ?? + normalizeModelCompat({ + id: trimmedModelId, + name: trimmedModelId, + api: "openai-responses", + provider: PROVIDER_ID, + baseUrl: OPENAI_BASE_URL, + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, + maxTokens: OPENAI_GPT_54_MAX_TOKENS, + } as ProviderRuntimeModel) + ); +} + +const openAIPlugin = { + id: PROVIDER_ID, + name: "OpenAI Provider", + description: "Bundled OpenAI provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "OpenAI", + docsPath: "/providers/models", + envVars: ["OPENAI_API_KEY"], + auth: [], + resolveDynamicModel: (ctx) => resolveOpenAIGpt54ForwardCompatModel(ctx), + normalizeResolvedModel: (ctx) => { + if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) { + return undefined; + } + return normalizeOpenAITransport(ctx.model); + }, + capabilities: { + providerFamily: "openai", + }, + }); + }, +}; + +export default openAIPlugin; diff --git a/extensions/openai/openclaw.plugin.json b/extensions/openai/openclaw.plugin.json new file mode 100644 index 00000000000..4bae96f3619 --- /dev/null +++ b/extensions/openai/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "openai", + "providers": ["openai"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/openai/package.json b/extensions/openai/package.json new file mode 100644 index 00000000000..c5e73ed8120 --- /dev/null +++ b/extensions/openai/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/openai-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw OpenAI provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/src/agents/pi-embedded-runner/cache-ttl.ts b/src/agents/pi-embedded-runner/cache-ttl.ts index 02075cd78cf..e5e577d331a 100644 --- a/src/agents/pi-embedded-runner/cache-ttl.ts +++ b/src/agents/pi-embedded-runner/cache-ttl.ts @@ -10,7 +10,7 @@ export type CacheTtlEntryData = { modelId?: string; }; -const CACHE_TTL_NATIVE_PROVIDERS = new Set(["anthropic", "moonshot", "zai"]); +const CACHE_TTL_NATIVE_PROVIDERS = new Set(["moonshot", "zai"]); export function isCacheTtlEligibleProvider(provider: string, modelId: string): boolean { const normalizedProvider = provider.toLowerCase(); @@ -28,6 +28,11 @@ export function isCacheTtlEligibleProvider(provider: string, modelId: string): b if (normalizedProvider === "kilocode" && normalizedModelId.startsWith("anthropic/")) { return true; } + // Legacy fallback for tests / plugin-disabled contexts. The Anthropic plugin + // owns this policy in normal runtime. + if (normalizedProvider === "anthropic") { + return true; + } if (CACHE_TTL_NATIVE_PROVIDERS.has(normalizedProvider)) { return true; } diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index ed6356a361f..7263155c1ad 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -34,7 +34,7 @@ type InlineProviderConfig = { headers?: unknown; }; -const PLUGIN_FIRST_DYNAMIC_PROVIDERS = new Set(["google-gemini-cli", "zai"]); +const PLUGIN_FIRST_DYNAMIC_PROVIDERS = new Set(["anthropic", "google-gemini-cli", "openai", "zai"]); function sanitizeModelHeaders( headers: unknown, diff --git a/src/agents/provider-capabilities.test.ts b/src/agents/provider-capabilities.test.ts index 8dee8776835..699cba9ffe5 100644 --- a/src/agents/provider-capabilities.test.ts +++ b/src/agents/provider-capabilities.test.ts @@ -2,6 +2,15 @@ import { describe, expect, it, vi } from "vitest"; const resolveProviderCapabilitiesWithPluginMock = vi.fn((params: { provider: string }) => { switch (params.provider) { + case "anthropic": + return { + providerFamily: "anthropic", + dropThinkingBlockModelHints: ["claude"], + }; + case "openai": + return { + providerFamily: "openai", + }; case "openrouter": return { openAiCompatTurnValidation: false, @@ -47,7 +56,7 @@ import { } from "./provider-capabilities.js"; describe("resolveProviderCapabilities", () => { - it("returns native anthropic defaults for ordinary providers", () => { + it("returns provider-owned anthropic defaults for ordinary providers", () => { expect(resolveProviderCapabilities("anthropic")).toEqual({ anthropicToolSchemaMode: "native", anthropicToolChoiceMode: "native", diff --git a/src/agents/provider-capabilities.ts b/src/agents/provider-capabilities.ts index 6f6f9fe4c9f..dab9fa8d812 100644 --- a/src/agents/provider-capabilities.ts +++ b/src/agents/provider-capabilities.ts @@ -28,20 +28,17 @@ const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = { }; const CORE_PROVIDER_CAPABILITIES: Record> = { - anthropic: { - providerFamily: "anthropic", - dropThinkingBlockModelHints: ["claude"], - }, "amazon-bedrock": { providerFamily: "anthropic", dropThinkingBlockModelHints: ["claude"], }, - openai: { - providerFamily: "openai", - }, }; const PLUGIN_CAPABILITIES_FALLBACKS: Record> = { + anthropic: { + providerFamily: "anthropic", + dropThinkingBlockModelHints: ["claude"], + }, mistral: { transcriptToolCallIdMode: "strict9", transcriptToolCallIdModelHints: [ @@ -64,6 +61,9 @@ const PLUGIN_CAPABILITIES_FALLBACKS: Record([ + "anthropic", "byteplus", "cloudflare-ai-gateway", "device-pair", @@ -38,6 +39,7 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ "moonshot", "nvidia", "ollama", + "openai", "openai-codex", "opencode", "opencode-go", diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index fdcd0bb67a9..68b83561461 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -5,6 +5,7 @@ import type { ProviderPlugin } from "./types.js"; const log = createSubsystemLogger("plugins"); const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [ + "anthropic", "byteplus", "cloudflare-ai-gateway", "copilot-proxy", @@ -20,6 +21,7 @@ const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "moonshot", "nvidia", "ollama", + "openai", "openai-codex", "opencode", "opencode-go", From 0537f3e597c46b50b67f05c1fe4d2ebeba4c3bee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 00:10:46 +0000 Subject: [PATCH 060/943] fix: repair onboarding setup-wizard imports --- src/commands/onboarding/registry.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts index f53e702c83e..fbc4424b303 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/onboarding/registry.ts @@ -1,6 +1,6 @@ import { discordOnboardingAdapter } from "../../../extensions/discord/src/setup-surface.js"; -import { imessageOnboardingAdapter } from "../../../extensions/imessage/src/onboarding.js"; -import { signalOnboardingAdapter } from "../../../extensions/signal/src/onboarding.js"; +import { imessagePlugin } from "../../../extensions/imessage/src/channel.js"; +import { signalPlugin } from "../../../extensions/signal/src/channel.js"; import { slackOnboardingAdapter } from "../../../extensions/slack/src/setup-surface.js"; import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; import { whatsappOnboardingAdapter } from "../../../extensions/whatsapp/src/onboarding.js"; @@ -13,6 +13,14 @@ const telegramOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ plugin: telegramPlugin, wizard: telegramPlugin.setupWizard!, }); +const signalOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: signalPlugin, + wizard: signalPlugin.setupWizard!, +}); +const imessageOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: imessagePlugin, + wizard: imessagePlugin.setupWizard!, +}); const BUILTIN_ONBOARDING_ADAPTERS: ChannelOnboardingAdapter[] = [ telegramOnboardingAdapter, From a9317a4c288ac617bf8a62a3d71e1af8a08ab340 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 00:11:26 +0000 Subject: [PATCH 061/943] test(discord): cover startup phase logging --- .../discord/src/monitor/provider.test.ts | 32 +++++++++++++++++++ extensions/discord/src/monitor/provider.ts | 1 - 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index 10d310b9a20..81f8fa9f5e1 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -829,4 +829,36 @@ describe("monitorDiscordProvider", () => { expect(connectedTrue).toBeDefined(); expect(connectedFalse).toBeDefined(); }); + + it("logs Discord startup phases and early gateway debug events", async () => { + const { monitorDiscordProvider } = await import("./provider.js"); + const runtime = baseRuntime(); + const emitter = new EventEmitter(); + const gateway = { emitter, isConnected: true, reconnectAttempts: 0 }; + clientGetPluginMock.mockImplementation((name: string) => + name === "gateway" ? gateway : undefined, + ); + clientFetchUserMock.mockImplementationOnce(async () => { + emitter.emit("debug", "WebSocket connection opened"); + return { id: "bot-1", username: "Molty" }; + }); + + await monitorDiscordProvider({ + config: baseConfig(), + runtime, + }); + + const messages = vi.mocked(runtime.log).mock.calls.map((call) => String(call[0])); + expect(messages.some((msg) => msg.includes("fetch-application-id:start"))).toBe(true); + expect(messages.some((msg) => msg.includes("fetch-application-id:done"))).toBe(true); + expect(messages.some((msg) => msg.includes("deploy-commands:start"))).toBe(true); + expect(messages.some((msg) => msg.includes("deploy-commands:done"))).toBe(true); + expect(messages.some((msg) => msg.includes("fetch-bot-identity:start"))).toBe(true); + expect(messages.some((msg) => msg.includes("fetch-bot-identity:done"))).toBe(true); + expect( + messages.some( + (msg) => msg.includes("gateway-debug") && msg.includes("WebSocket connection opened"), + ), + ).toBe(true); + }); }); diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 8fa3335fa3a..de174b9d8bf 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -367,7 +367,6 @@ function logDiscordStartupPhase(params: { `discord startup [${params.accountId}] ${params.phase} ${elapsedMs}ms${suffix ? ` ${suffix}` : ""}`, ); } - function formatDiscordDeployErrorDetails(err: unknown): string { if (!err || typeof err !== "object") { return ""; From c156f7c7e3e24ccddb97bb8b142232dc9bd744c1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 00:24:44 +0000 Subject: [PATCH 062/943] fix: reduce plugin and discord warning noise --- extensions/discord/src/monitor/listeners.ts | 3 +- .../src/monitor/thread-session-close.test.ts | 18 ++++++++ .../src/monitor/thread-session-close.ts | 3 ++ extensions/tlon/index.ts | 28 +++++++----- scripts/copy-bundled-plugin-metadata.mjs | 25 ++++++++++- src/logging/console-capture.test.ts | 23 +++++----- src/logging/console.ts | 8 +++- .../copy-bundled-plugin-metadata.test.ts | 43 +++++++++++++++++++ src/plugins/manifest-registry.test.ts | 38 ++++++++++++++++ src/plugins/manifest-registry.ts | 13 +++++- 10 files changed, 175 insertions(+), 27 deletions(-) diff --git a/extensions/discord/src/monitor/listeners.ts b/extensions/discord/src/monitor/listeners.ts index b0dd33543b0..318435d5318 100644 --- a/extensions/discord/src/monitor/listeners.ts +++ b/extensions/discord/src/monitor/listeners.ts @@ -755,14 +755,13 @@ export class DiscordThreadUpdateListener extends ThreadUpdateListener { return; } const logger = this.logger ?? discordEventQueueLog; - logger.info("Discord thread archived — resetting session", { threadId }); const count = await closeDiscordThreadSessions({ cfg: this.cfg, accountId: this.accountId, threadId, }); if (count > 0) { - logger.info("Discord thread sessions reset after archival", { threadId, count }); + logger.info("Discord thread archived — reset sessions", { threadId, count }); } }, onError: (err) => { diff --git a/extensions/discord/src/monitor/thread-session-close.test.ts b/extensions/discord/src/monitor/thread-session-close.test.ts index 1f70084facf..f2109150c66 100644 --- a/extensions/discord/src/monitor/thread-session-close.test.ts +++ b/extensions/discord/src/monitor/thread-session-close.test.ts @@ -135,6 +135,24 @@ describe("closeDiscordThreadSessions", () => { expect(hoisted.updateSessionStore).not.toHaveBeenCalled(); }); + it("does not recount sessions that were already reset", async () => { + const store = { + [MATCHED_KEY]: { updatedAt: 0 }, + [UNMATCHED_KEY]: { updatedAt: 1_700_000_000_001 }, + }; + setupStore(store); + + const count = await closeDiscordThreadSessions({ + cfg: {}, + accountId: "default", + threadId: THREAD_ID, + }); + + expect(count).toBe(0); + expect(store[MATCHED_KEY].updatedAt).toBe(0); + expect(store[UNMATCHED_KEY].updatedAt).toBe(1_700_000_000_001); + }); + it("resolves the store path using cfg.session.store and accountId", async () => { const store = {}; setupStore(store); diff --git a/extensions/discord/src/monitor/thread-session-close.ts b/extensions/discord/src/monitor/thread-session-close.ts index 234a886d96e..ca73f623bd0 100644 --- a/extensions/discord/src/monitor/thread-session-close.ts +++ b/extensions/discord/src/monitor/thread-session-close.ts @@ -47,6 +47,9 @@ export async function closeDiscordThreadSessions(params: { if (!entry || !sessionKeyContainsThreadId(key)) { continue; } + if (entry.updatedAt === 0) { + continue; + } // Setting updatedAt to 0 signals that this session is stale. // evaluateSessionFreshness will create a new session on the next message. entry.updatedAt = 0; diff --git a/extensions/tlon/index.ts b/extensions/tlon/index.ts index 4365253a1fc..36be4651b1d 100644 --- a/extensions/tlon/index.ts +++ b/extensions/tlon/index.ts @@ -27,25 +27,32 @@ const ALLOWED_TLON_COMMANDS = new Set([ /** * Find the tlon binary from the skill package */ +let cachedTlonBinary: string | undefined; + function findTlonBinary(): string { + if (cachedTlonBinary) { + return cachedTlonBinary; + } // Check in node_modules/.bin const skillBin = join(__dirname, "node_modules", ".bin", "tlon"); - console.log(`[tlon] Checking for binary at: ${skillBin}, exists: ${existsSync(skillBin)}`); - if (existsSync(skillBin)) return skillBin; + if (existsSync(skillBin)) { + cachedTlonBinary = skillBin; + return skillBin; + } // Check for platform-specific binary directly const platform = process.platform; const arch = process.arch; const platformPkg = `@tloncorp/tlon-skill-${platform}-${arch}`; const platformBin = join(__dirname, "node_modules", platformPkg, "tlon"); - console.log( - `[tlon] Checking for platform binary at: ${platformBin}, exists: ${existsSync(platformBin)}`, - ); - if (existsSync(platformBin)) return platformBin; + if (existsSync(platformBin)) { + cachedTlonBinary = platformBin; + return platformBin; + } // Fallback to PATH - console.log(`[tlon] Falling back to PATH lookup for 'tlon'`); - return "tlon"; + cachedTlonBinary = "tlon"; + return cachedTlonBinary; } /** @@ -132,9 +139,7 @@ const plugin = { setTlonRuntime(api.runtime); api.registerChannel({ plugin: tlonPlugin }); - // Register the tlon tool - const tlonBinary = findTlonBinary(); - api.logger.info(`[tlon] Registering tlon tool, binary: ${tlonBinary}`); + api.logger.debug?.("[tlon] Registering tlon tool"); api.registerTool({ name: "tlon", label: "Tlon CLI", @@ -156,6 +161,7 @@ const plugin = { async execute(_id: string, params: { command: string }) { try { const args = shellSplit(params.command); + const tlonBinary = findTlonBinary(); // Validate first argument is a whitelisted tlon subcommand const subcommand = args[0]; diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs index 2ba04d9cda0..426f319c02c 100644 --- a/scripts/copy-bundled-plugin-metadata.mjs +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -40,6 +40,18 @@ function normalizeManifestRelativePath(rawPath) { return rawPath.replaceAll("\\", "/").replace(/^\.\//u, ""); } +function resolveDeclaredSkillSourcePath(params) { + const normalized = normalizeManifestRelativePath(params.rawPath); + const pluginLocalPath = ensurePathInsideRoot(params.pluginDir, normalized); + if (fs.existsSync(pluginLocalPath)) { + return pluginLocalPath; + } + if (!/^node_modules(?:\/|$)/u.test(normalized)) { + return pluginLocalPath; + } + return ensurePathInsideRoot(params.repoRoot, normalized); +} + function resolveBundledSkillTarget(rawPath) { const normalized = normalizeManifestRelativePath(rawPath); if (/^node_modules(?:\/|$)/u.test(normalized)) { @@ -68,7 +80,11 @@ function copyDeclaredPluginSkillPaths(params) { if (typeof raw !== "string" || raw.trim().length === 0) { continue; } - const sourcePath = ensurePathInsideRoot(params.pluginDir, raw); + const sourcePath = resolveDeclaredSkillSourcePath({ + rawPath: raw, + pluginDir: params.pluginDir, + repoRoot: params.repoRoot, + }); const target = resolveBundledSkillTarget(raw); if (!fs.existsSync(sourcePath)) { // Some Docker/lightweight builds intentionally omit optional plugin-local @@ -138,7 +154,12 @@ export function copyBundledPluginMetadata(params = {}) { // remove the older bad node_modules tree so release packs cannot pick it up. removePathIfExists(path.join(distPluginDir, GENERATED_BUNDLED_SKILLS_DIR)); removePathIfExists(path.join(distPluginDir, "node_modules")); - const copiedSkills = copyDeclaredPluginSkillPaths({ manifest, pluginDir, distPluginDir }); + const copiedSkills = copyDeclaredPluginSkillPaths({ + manifest, + pluginDir, + distPluginDir, + repoRoot, + }); const bundledManifest = Array.isArray(manifest.skills) ? { ...manifest, skills: copiedSkills } : manifest; diff --git a/src/logging/console-capture.test.ts b/src/logging/console-capture.test.ts index 87827c23927..cc5d6a6638f 100644 --- a/src/logging/console-capture.test.ts +++ b/src/logging/console-capture.test.ts @@ -77,16 +77,19 @@ describe("enableConsoleCapture", () => { vi.useRealTimers(); }); - it("suppresses discord EventQueue slow listener duplicates", () => { - setLoggerOverride({ level: "info", file: tempLogPath() }); - const warn = vi.fn(); - console.warn = warn; - enableConsoleCapture(); - console.warn( - "[EventQueue] Slow listener detected: DiscordMessageListener took 12.3 seconds for event MESSAGE_CREATE", - ); - expect(warn).not.toHaveBeenCalled(); - }); + it.each(["DiscordMessageListener", "DiscordReactionListener", "DiscordReactionRemoveListener"])( + "suppresses discord EventQueue slow listener duplicates for %s", + (listener) => { + setLoggerOverride({ level: "info", file: tempLogPath() }); + const warn = vi.fn(); + console.warn = warn; + enableConsoleCapture(); + console.warn( + `[EventQueue] Slow listener detected: ${listener} took 12.3 seconds for event MESSAGE_CREATE`, + ); + expect(warn).not.toHaveBeenCalled(); + }, + ); it("does not double-prefix timestamps", () => { setLoggerOverride({ level: "info", file: tempLogPath() }); diff --git a/src/logging/console.ts b/src/logging/console.ts index c1970def562..5d3b95d1aea 100644 --- a/src/logging/console.ts +++ b/src/logging/console.ts @@ -145,6 +145,12 @@ const SUPPRESSED_CONSOLE_PREFIXES = [ "Session already open", ] as const; +const SUPPRESSED_DISCORD_EVENTQUEUE_LISTENERS = [ + "DiscordMessageListener", + "DiscordReactionListener", + "DiscordReactionRemoveListener", +] as const; + function shouldSuppressConsoleMessage(message: string): boolean { if (isVerbose()) { return false; @@ -154,7 +160,7 @@ function shouldSuppressConsoleMessage(message: string): boolean { } if ( message.startsWith("[EventQueue] Slow listener detected") && - message.includes("DiscordMessageListener") + SUPPRESSED_DISCORD_EVENTQUEUE_LISTENERS.some((listener) => message.includes(listener)) ) { return true; } diff --git a/src/plugins/copy-bundled-plugin-metadata.test.ts b/src/plugins/copy-bundled-plugin-metadata.test.ts index 9c980381aa8..88da85b0dda 100644 --- a/src/plugins/copy-bundled-plugin-metadata.test.ts +++ b/src/plugins/copy-bundled-plugin-metadata.test.ts @@ -152,6 +152,49 @@ describe("copyBundledPluginMetadata", () => { expect(bundledManifest.skills).toEqual(["./bundled-skills/@tloncorp/tlon-skill"]); }); + it("falls back to repo-root hoisted node_modules skill paths", () => { + const repoRoot = makeRepoRoot("openclaw-bundled-plugin-hoisted-skill-"); + const pluginDir = path.join(repoRoot, "extensions", "tlon"); + const hoistedSkillDir = path.join(repoRoot, "node_modules", "@tloncorp", "tlon-skill"); + fs.mkdirSync(hoistedSkillDir, { recursive: true }); + fs.writeFileSync(path.join(hoistedSkillDir, "SKILL.md"), "# Hoisted Tlon Skill\n", "utf8"); + fs.mkdirSync(pluginDir, { recursive: true }); + writeJson(path.join(pluginDir, "openclaw.plugin.json"), { + id: "tlon", + configSchema: { type: "object" }, + skills: ["node_modules/@tloncorp/tlon-skill"], + }); + writeJson(path.join(pluginDir, "package.json"), { + name: "@openclaw/tlon", + openclaw: { extensions: ["./index.ts"] }, + }); + + copyBundledPluginMetadata({ repoRoot }); + + expect( + fs.readFileSync( + path.join( + repoRoot, + "dist", + "extensions", + "tlon", + "bundled-skills", + "@tloncorp", + "tlon-skill", + "SKILL.md", + ), + "utf8", + ), + ).toContain("Hoisted Tlon Skill"); + const bundledManifest = JSON.parse( + fs.readFileSync( + path.join(repoRoot, "dist", "extensions", "tlon", "openclaw.plugin.json"), + "utf8", + ), + ) as { skills?: string[] }; + expect(bundledManifest.skills).toEqual(["./bundled-skills/@tloncorp/tlon-skill"]); + }); + it("omits missing declared skill paths and removes stale generated outputs", () => { const repoRoot = makeRepoRoot("openclaw-bundled-plugin-missing-skill-"); const pluginDir = path.join(repoRoot, "extensions", "tlon"); diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index a05576bc96d..6f4c0353330 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -314,6 +314,44 @@ describe("loadPluginManifestRegistry", () => { expect(countDuplicateWarnings(loadRegistry(candidates))).toBe(0); }); + it("accepts provider-style id hints without warning", () => { + const dir = makeTempDir(); + writeManifest(dir, { id: "openai", configSchema: { type: "object" } }); + + const registry = loadRegistry([ + createPluginCandidate({ + idHint: "openai-provider", + rootDir: dir, + origin: "bundled", + }), + ]); + + expect(registry.diagnostics.some((diag) => diag.message.includes("plugin id mismatch"))).toBe( + false, + ); + }); + + it("still warns for unrelated id hint mismatches", () => { + const dir = makeTempDir(); + writeManifest(dir, { id: "openai", configSchema: { type: "object" } }); + + const registry = loadRegistry([ + createPluginCandidate({ + idHint: "totally-different", + rootDir: dir, + origin: "bundled", + }), + ]); + + expect( + registry.diagnostics.some((diag) => + diag.message.includes( + 'plugin id mismatch (manifest uses "openai", entry hints "totally-different")', + ), + ), + ).toBe(true); + }); + it("loads Codex bundle manifests into the registry", () => { const bundleDir = makeTempDir(); mkdirSafe(path.join(bundleDir, ".codex-plugin")); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index b0f98b3beef..48fdae50d95 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -122,6 +122,17 @@ function normalizeManifestLabel(raw: string | undefined): string | undefined { return trimmed ? trimmed : undefined; } +function isCompatiblePluginIdHint(idHint: string | undefined, manifestId: string): boolean { + const normalizedHint = idHint?.trim(); + if (!normalizedHint) { + return true; + } + if (normalizedHint === manifestId) { + return true; + } + return normalizedHint === `${manifestId}-provider`; +} + function buildRecord(params: { manifest: PluginManifest; candidate: PluginCandidate; @@ -304,7 +315,7 @@ export function loadPluginManifestRegistry(params: { } const manifest = manifestRes.manifest; - if (candidate.idHint && candidate.idHint !== manifest.id) { + if (!isCompatiblePluginIdHint(candidate.idHint, manifest.id)) { diagnostics.push({ level: "warn", pluginId: manifest.id, From dd96be4e9543e7f0e417ce912a5b36176bfb7e41 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 17:29:10 -0700 Subject: [PATCH 063/943] chore: raise plugin registry cache cap --- src/plugins/loader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index b9132c08f33..6f32ee0d151 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -54,7 +54,7 @@ export type PluginLoadOptions = { activate?: boolean; }; -const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 32; +const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 128; const registryCache = new Map(); const openAllowlistWarningCache = new Set(); From eb97535a35c2785df31d1bfdacf9c621755bbb07 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 00:29:33 +0000 Subject: [PATCH 064/943] build: suppress protobufjs eval warning in tsdown --- tsdown.config.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tsdown.config.ts b/tsdown.config.ts index b1aa8749307..6ed9ccb930b 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -13,14 +13,30 @@ function buildInputOptions(options: { onLog?: unknown; [key: string]: unknown }) const previousOnLog = typeof options.onLog === "function" ? options.onLog : undefined; + function isSuppressedLog(log: { + code?: string; + message?: string; + id?: string; + importer?: string; + }) { + if (log.code === "PLUGIN_TIMINGS") { + return true; + } + if (log.code !== "EVAL") { + return false; + } + const haystack = [log.message, log.id, log.importer].filter(Boolean).join("\n"); + return haystack.includes("@protobufjs/inquire/index.js"); + } + return { ...options, onLog( level: string, - log: { code?: string }, + log: { code?: string; message?: string; id?: string; importer?: string }, defaultHandler: (level: string, log: { code?: string }) => void, ) { - if (log.code === "PLUGIN_TIMINGS") { + if (isSuppressedLog(log)) { return; } if (typeof previousOnLog === "function") { From cbb8c43f60c781f88f4b4adc668fb43e2e890249 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 17:33:46 -0700 Subject: [PATCH 065/943] refactor: tighten setup wizard onboarding bridge --- extensions/discord/src/setup-surface.ts | 35 +-- extensions/slack/src/setup-surface.ts | 35 +-- src/channels/plugins/onboarding/helpers.ts | 2 +- src/channels/plugins/setup-wizard.ts | 275 ++++++++++++--------- src/commands/onboarding/registry.ts | 12 +- 5 files changed, 175 insertions(+), 184 deletions(-) diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index eb4db7eda65..e03c7ef1e16 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -1,7 +1,4 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../../../src/channels/plugins/onboarding-types.js"; +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { noteChannelLookupFailure, noteChannelLookupSummary, @@ -16,12 +13,8 @@ import { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, } from "../../../src/channels/plugins/setup-helpers.js"; -import { - buildChannelOnboardingAdapterFromSetupWizard, - type ChannelSetupWizard, -} from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import { getChatChannelMeta } from "../../../src/channels/registry.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DiscordGuildEntry } from "../../../src/config/types.discord.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; @@ -397,27 +390,3 @@ export const discordSetupWizard: ChannelSetupWizard = { dmPolicy: discordDmPolicy, disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), }; - -const discordSetupPlugin = { - id: channel, - meta: { - ...getChatChannelMeta(channel), - quickstartAllowFrom: true, - }, - config: { - listAccountIds: listDiscordAccountIds, - resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => - resolveDiscordAccount({ cfg, accountId }), - resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => { - const resolved = resolveDiscordAccount({ cfg, accountId }); - return resolved.config.allowFrom ?? resolved.config.dm?.allowFrom; - }, - }, - setup: discordSetupAdapter, -} as const; - -export const discordOnboardingAdapter: ChannelOnboardingAdapter = - buildChannelOnboardingAdapterFromSetupWizard({ - plugin: discordSetupPlugin, - wizard: discordSetupWizard, - }); diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index 7d90bba937c..ad743ffa080 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -1,7 +1,4 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../../../src/channels/plugins/onboarding-types.js"; +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { noteChannelLookupFailure, noteChannelLookupSummary, @@ -17,13 +14,11 @@ import { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, } from "../../../src/channels/plugins/setup-helpers.js"; -import { - buildChannelOnboardingAdapterFromSetupWizard, - type ChannelSetupWizard, - type ChannelSetupWizardAllowFromEntry, +import type { + ChannelSetupWizard, + ChannelSetupWizardAllowFromEntry, } from "../../../src/channels/plugins/setup-wizard.js"; import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import { getChatChannelMeta } from "../../../src/channels/registry.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; @@ -507,25 +502,3 @@ export const slackSetupWizard: ChannelSetupWizard = { }, disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), }; - -const slackSetupPlugin = { - id: channel, - meta: { - ...getChatChannelMeta(channel), - quickstartAllowFrom: true, - }, - config: { - listAccountIds: listSlackAccountIds, - resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => - resolveSlackAccount({ cfg, accountId }), - resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => - resolveSlackAccount({ cfg, accountId }).dm?.allowFrom, - }, - setup: slackSetupAdapter, -} as const; - -export const slackOnboardingAdapter: ChannelOnboardingAdapter = - buildChannelOnboardingAdapterFromSetupWizard({ - plugin: slackSetupPlugin, - wizard: slackSetupWizard, - }); diff --git a/src/channels/plugins/onboarding/helpers.ts b/src/channels/plugins/onboarding/helpers.ts index 77d03a4127a..d26999bd3ff 100644 --- a/src/channels/plugins/onboarding/helpers.ts +++ b/src/channels/plugins/onboarding/helpers.ts @@ -340,7 +340,7 @@ export function patchLegacyDmChannelConfig(params: { export function setOnboardingChannelEnabled( cfg: OpenClawConfig, - channel: AccountScopedChannel, + channel: string, enabled: boolean, ): OpenClawConfig { const channelConfig = (cfg.channels?.[channel] as Record | undefined) ?? {}; diff --git a/src/channels/plugins/setup-wizard.ts b/src/channels/plugins/setup-wizard.ts index cb446a1bc76..b9dc4085dc4 100644 --- a/src/channels/plugins/setup-wizard.ts +++ b/src/channels/plugins/setup-wizard.ts @@ -48,7 +48,7 @@ export type ChannelSetupWizardCredentialState = { envValue?: string; }; -type ChannelSetupWizardCredentialValues = Partial>; +type ChannelSetupWizardCredentialValues = Partial>; export type ChannelSetupWizardNote = { title: string; @@ -85,6 +85,13 @@ export type ChannelSetupWizardCredential = { cfg: OpenClawConfig; accountId: string; }) => ChannelSetupWizardCredentialState; + shouldPrompt?: (params: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: ChannelSetupWizardCredentialValues; + currentValue?: string; + state: ChannelSetupWizardCredentialState; + }) => boolean | Promise; applyUseEnv?: (params: { cfg: OpenClawConfig; accountId: string; @@ -92,6 +99,7 @@ export type ChannelSetupWizardCredential = { applySet?: (params: { cfg: OpenClawConfig; accountId: string; + credentialValues: ChannelSetupWizardCredentialValues; value: unknown; resolvedValue: string; }) => OpenClawConfig | Promise; @@ -221,6 +229,7 @@ export type ChannelSetupWizard = { introNote?: ChannelSetupWizardNote; envShortcut?: ChannelSetupWizardEnvShortcut; prepare?: ChannelSetupWizardPrepare; + stepOrder?: "credentials-first" | "text-first"; credentials: ChannelSetupWizardCredential[]; textInputs?: ChannelSetupWizardTextInput[]; completionNote?: ChannelSetupWizardNote; @@ -442,10 +451,30 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { } } - if (!usedEnvShortcut) { + const runCredentialSteps = async () => { + if (usedEnvShortcut) { + return; + } for (const credential of wizard.credentials) { let credentialState = credential.inspect({ cfg: next, accountId }); let resolvedCredentialValue = trimResolvedValue(credentialState.resolvedValue); + const shouldPrompt = credential.shouldPrompt + ? await credential.shouldPrompt({ + cfg: next, + accountId, + credentialValues, + currentValue: resolvedCredentialValue, + state: credentialState, + }) + : true; + if (!shouldPrompt) { + if (resolvedCredentialValue) { + credentialValues[credential.inputKey] = resolvedCredentialValue; + } else { + delete credentialValues[credential.inputKey]; + } + continue; + } const allowEnv = credential.allowEnv?.({ cfg: next, accountId }) ?? false; const credentialResult = await runSingleChannelSecretStep({ @@ -492,6 +521,7 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { ? await credential.applySet({ cfg: currentCfg, accountId, + credentialValues, value, resolvedValue, }) @@ -518,129 +548,140 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { delete credentialValues[credential.inputKey]; } } - } + }; - for (const textInput of wizard.textInputs ?? []) { - let currentValue = trimResolvedValue( - typeof credentialValues[textInput.inputKey] === "string" - ? credentialValues[textInput.inputKey] - : undefined, - ); - if (!currentValue && textInput.currentValue) { - currentValue = trimResolvedValue( - await textInput.currentValue({ - cfg: next, - accountId, - credentialValues, - }), + const runTextInputSteps = async () => { + for (const textInput of wizard.textInputs ?? []) { + let currentValue = trimResolvedValue( + typeof credentialValues[textInput.inputKey] === "string" + ? credentialValues[textInput.inputKey] + : undefined, ); - } - const shouldPrompt = textInput.shouldPrompt - ? await textInput.shouldPrompt({ - cfg: next, - accountId, - credentialValues, - currentValue, - }) - : true; - - if (!shouldPrompt) { - if (currentValue) { - credentialValues[textInput.inputKey] = currentValue; - if (textInput.applyCurrentValue) { - next = await applyWizardTextInputValue({ - plugin, - input: textInput, - cfg: next, - accountId, - value: currentValue, - }); - } - } - continue; - } - - if (textInput.helpLines && textInput.helpLines.length > 0) { - await prompter.note( - textInput.helpLines.join("\n"), - textInput.helpTitle ?? textInput.message, - ); - } - - if (currentValue && textInput.confirmCurrentValue !== false) { - const keep = await prompter.confirm({ - message: - typeof textInput.keepPrompt === "function" - ? textInput.keepPrompt(currentValue) - : (textInput.keepPrompt ?? `${textInput.message} set (${currentValue}). Keep it?`), - initialValue: true, - }); - if (keep) { - credentialValues[textInput.inputKey] = currentValue; - if (textInput.applyCurrentValue) { - next = await applyWizardTextInputValue({ - plugin, - input: textInput, - cfg: next, - accountId, - value: currentValue, - }); - } - continue; - } - } - - const initialValue = trimResolvedValue( - (await textInput.initialValue?.({ - cfg: next, - accountId, - credentialValues, - })) ?? currentValue, - ); - const rawValue = String( - await prompter.text({ - message: textInput.message, - initialValue, - placeholder: textInput.placeholder, - validate: (value) => { - const trimmed = String(value ?? "").trim(); - if (!trimmed && textInput.required !== false) { - return "Required"; - } - return textInput.validate?.({ - value: trimmed, + if (!currentValue && textInput.currentValue) { + currentValue = trimResolvedValue( + await textInput.currentValue({ cfg: next, accountId, credentialValues, - }); - }, - }), - ); - const trimmedValue = rawValue.trim(); - if (!trimmedValue && textInput.required === false) { - delete credentialValues[textInput.inputKey]; - continue; - } - const normalizedValue = trimResolvedValue( - textInput.normalizeValue?.({ - value: trimmedValue, + }), + ); + } + const shouldPrompt = textInput.shouldPrompt + ? await textInput.shouldPrompt({ + cfg: next, + accountId, + credentialValues, + currentValue, + }) + : true; + + if (!shouldPrompt) { + if (currentValue) { + credentialValues[textInput.inputKey] = currentValue; + if (textInput.applyCurrentValue) { + next = await applyWizardTextInputValue({ + plugin, + input: textInput, + cfg: next, + accountId, + value: currentValue, + }); + } + } + continue; + } + + if (textInput.helpLines && textInput.helpLines.length > 0) { + await prompter.note( + textInput.helpLines.join("\n"), + textInput.helpTitle ?? textInput.message, + ); + } + + if (currentValue && textInput.confirmCurrentValue !== false) { + const keep = await prompter.confirm({ + message: + typeof textInput.keepPrompt === "function" + ? textInput.keepPrompt(currentValue) + : (textInput.keepPrompt ?? + `${textInput.message} set (${currentValue}). Keep it?`), + initialValue: true, + }); + if (keep) { + credentialValues[textInput.inputKey] = currentValue; + if (textInput.applyCurrentValue) { + next = await applyWizardTextInputValue({ + plugin, + input: textInput, + cfg: next, + accountId, + value: currentValue, + }); + } + continue; + } + } + + const initialValue = trimResolvedValue( + (await textInput.initialValue?.({ + cfg: next, + accountId, + credentialValues, + })) ?? currentValue, + ); + const rawValue = String( + await prompter.text({ + message: textInput.message, + initialValue, + placeholder: textInput.placeholder, + validate: (value) => { + const trimmed = String(value ?? "").trim(); + if (!trimmed && textInput.required !== false) { + return "Required"; + } + return textInput.validate?.({ + value: trimmed, + cfg: next, + accountId, + credentialValues, + }); + }, + }), + ); + const trimmedValue = rawValue.trim(); + if (!trimmedValue && textInput.required === false) { + delete credentialValues[textInput.inputKey]; + continue; + } + const normalizedValue = trimResolvedValue( + textInput.normalizeValue?.({ + value: trimmedValue, + cfg: next, + accountId, + credentialValues, + }) ?? trimmedValue, + ); + if (!normalizedValue) { + delete credentialValues[textInput.inputKey]; + continue; + } + next = await applyWizardTextInputValue({ + plugin, + input: textInput, cfg: next, accountId, - credentialValues, - }) ?? trimmedValue, - ); - if (!normalizedValue) { - delete credentialValues[textInput.inputKey]; - continue; + value: normalizedValue, + }); + credentialValues[textInput.inputKey] = normalizedValue; } - next = await applyWizardTextInputValue({ - plugin, - input: textInput, - cfg: next, - accountId, - value: normalizedValue, - }); - credentialValues[textInput.inputKey] = normalizedValue; + }; + + if (wizard.stepOrder === "text-first") { + await runTextInputSteps(); + await runCredentialSteps(); + } else { + await runCredentialSteps(); + await runTextInputSteps(); } if (wizard.groupAccess) { diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts index fbc4424b303..40bec8720f1 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/onboarding/registry.ts @@ -1,7 +1,7 @@ -import { discordOnboardingAdapter } from "../../../extensions/discord/src/setup-surface.js"; +import { discordPlugin } from "../../../extensions/discord/src/channel.js"; import { imessagePlugin } from "../../../extensions/imessage/src/channel.js"; import { signalPlugin } from "../../../extensions/signal/src/channel.js"; -import { slackOnboardingAdapter } from "../../../extensions/slack/src/setup-surface.js"; +import { slackPlugin } from "../../../extensions/slack/src/channel.js"; import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; import { whatsappOnboardingAdapter } from "../../../extensions/whatsapp/src/onboarding.js"; import { listChannelSetupPlugins } from "../../channels/plugins/setup-registry.js"; @@ -13,6 +13,14 @@ const telegramOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ plugin: telegramPlugin, wizard: telegramPlugin.setupWizard!, }); +const discordOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: discordPlugin, + wizard: discordPlugin.setupWizard!, +}); +const slackOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: slackPlugin, + wizard: slackPlugin.setupWizard!, +}); const signalOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ plugin: signalPlugin, wizard: signalPlugin.setupWizard!, From bad65f130e78eaea9865a252776844295c2e611c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 17:33:49 -0700 Subject: [PATCH 066/943] refactor: move bluebubbles to setup wizard --- extensions/bluebubbles/src/channel.ts | 62 +-- .../src/onboarding.secret-input.test.ts | 89 ---- extensions/bluebubbles/src/onboarding.ts | 289 ------------- .../bluebubbles/src/setup-surface.test.ts | 154 +++++++ extensions/bluebubbles/src/setup-surface.ts | 385 ++++++++++++++++++ src/plugin-sdk/bluebubbles.ts | 4 - 6 files changed, 543 insertions(+), 440 deletions(-) delete mode 100644 extensions/bluebubbles/src/onboarding.secret-input.test.ts delete mode 100644 extensions/bluebubbles/src/onboarding.ts create mode 100644 extensions/bluebubbles/src/setup-surface.test.ts create mode 100644 extensions/bluebubbles/src/setup-surface.ts diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 747fba5b67b..a482632ebea 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -1,18 +1,11 @@ -import type { - ChannelAccountSnapshot, - ChannelPlugin, - OpenClawConfig, -} from "openclaw/plugin-sdk/bluebubbles"; +import type { ChannelAccountSnapshot, ChannelPlugin } from "openclaw/plugin-sdk/bluebubbles"; import { - applyAccountNameToChannelSection, buildChannelConfigSchema, buildComputedAccountStatusSnapshot, buildProbeChannelStatusSummary, collectBlueBubblesStatusIssues, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, - migrateBaseNameToDefaultAccount, - normalizeAccountId, PAIRING_APPROVED_MESSAGE, resolveBlueBubblesGroupRequireMention, resolveBlueBubblesGroupToolPolicy, @@ -32,14 +25,13 @@ import { resolveDefaultBlueBubblesAccountId, } from "./accounts.js"; import { bluebubblesMessageActions } from "./actions.js"; -import { applyBlueBubblesConnectionConfig } from "./config-apply.js"; import { BlueBubblesConfigSchema } from "./config-schema.js"; import { sendBlueBubblesMedia } from "./media-send.js"; import { resolveBlueBubblesMessageId } from "./monitor.js"; import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js"; -import { blueBubblesOnboardingAdapter } from "./onboarding.js"; import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js"; import { sendMessageBlueBubbles } from "./send.js"; +import { blueBubblesSetupAdapter, blueBubblesSetupWizard } from "./setup-surface.js"; import { extractHandleFromChatGuid, looksLikeBlueBubblesTargetId, @@ -88,7 +80,7 @@ export const bluebubblesPlugin: ChannelPlugin = { }, reload: { configPrefixes: ["channels.bluebubbles"] }, configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema), - onboarding: blueBubblesOnboardingAdapter, + setupWizard: blueBubblesSetupWizard, config: { listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg: cfg, accountId }), @@ -223,53 +215,7 @@ export const bluebubblesPlugin: ChannelPlugin = { return display?.trim() || target?.trim() || ""; }, }, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "bluebubbles", - accountId, - name, - }), - validateInput: ({ input }) => { - if (!input.httpUrl && !input.password) { - return "BlueBubbles requires --http-url and --password."; - } - if (!input.httpUrl) { - return "BlueBubbles requires --http-url."; - } - if (!input.password) { - return "BlueBubbles requires --password."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "bluebubbles", - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "bluebubbles", - }) - : namedConfig; - return applyBlueBubblesConnectionConfig({ - cfg: next, - accountId, - patch: { - serverUrl: input.httpUrl, - password: input.password, - webhookPath: input.webhookPath, - }, - onlyDefinedFields: true, - }); - }, - }, + setup: blueBubblesSetupAdapter, pairing: { idLabel: "bluebubblesSenderId", normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")), diff --git a/extensions/bluebubbles/src/onboarding.secret-input.test.ts b/extensions/bluebubbles/src/onboarding.secret-input.test.ts deleted file mode 100644 index af59594f377..00000000000 --- a/extensions/bluebubbles/src/onboarding.secret-input.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { WizardPrompter } from "openclaw/plugin-sdk/bluebubbles"; -import { describe, expect, it, vi } from "vitest"; - -vi.mock("openclaw/plugin-sdk/bluebubbles", () => ({ - DEFAULT_ACCOUNT_ID: "default", - addWildcardAllowFrom: vi.fn(), - formatDocsLink: (_url: string, fallback: string) => fallback, - hasConfiguredSecretInput: (value: unknown) => { - if (typeof value === "string") { - return value.trim().length > 0; - } - if (!value || typeof value !== "object" || Array.isArray(value)) { - return false; - } - const ref = value as { source?: unknown; provider?: unknown; id?: unknown }; - const validSource = ref.source === "env" || ref.source === "file" || ref.source === "exec"; - return ( - validSource && - typeof ref.provider === "string" && - ref.provider.trim().length > 0 && - typeof ref.id === "string" && - ref.id.trim().length > 0 - ); - }, - mergeAllowFromEntries: (_existing: unknown, entries: string[]) => entries, - createAccountListHelpers: () => ({ - listAccountIds: () => ["default"], - resolveDefaultAccountId: () => "default", - }), - normalizeSecretInputString: (value: unknown) => { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; - }, - normalizeAccountId: (value?: string | null) => - value && value.trim().length > 0 ? value : "default", - promptAccountId: vi.fn(), - resolveAccountIdForConfigure: async (params: { - accountOverride?: string; - defaultAccountId: string; - }) => params.accountOverride?.trim() || params.defaultAccountId, -})); - -describe("bluebubbles onboarding SecretInput", () => { - it("preserves existing password SecretRef when user keeps current credential", async () => { - const { blueBubblesOnboardingAdapter } = await import("./onboarding.js"); - type ConfigureContext = Parameters< - NonNullable - >[0]; - const passwordRef = { source: "env", provider: "default", id: "BLUEBUBBLES_PASSWORD" }; - const confirm = vi - .fn() - .mockResolvedValueOnce(true) // keep server URL - .mockResolvedValueOnce(true) // keep password SecretRef - .mockResolvedValueOnce(false); // keep default webhook path - const text = vi.fn(); - const note = vi.fn(); - - const prompter = { - confirm, - text, - note, - } as unknown as WizardPrompter; - - const context = { - cfg: { - channels: { - bluebubbles: { - enabled: true, - serverUrl: "http://127.0.0.1:1234", - password: passwordRef, - }, - }, - }, - prompter, - runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"], - forceAllowFrom: false, - accountOverrides: {}, - shouldPromptAccountIds: false, - } satisfies ConfigureContext; - - const result = await blueBubblesOnboardingAdapter.configure(context); - - expect(result.cfg.channels?.bluebubbles?.password).toEqual(passwordRef); - expect(text).not.toHaveBeenCalled(); - }); -}); diff --git a/extensions/bluebubbles/src/onboarding.ts b/extensions/bluebubbles/src/onboarding.ts deleted file mode 100644 index eb66afdfe21..00000000000 --- a/extensions/bluebubbles/src/onboarding.ts +++ /dev/null @@ -1,289 +0,0 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, - OpenClawConfig, - DmPolicy, - WizardPrompter, -} from "openclaw/plugin-sdk/bluebubbles"; -import { - DEFAULT_ACCOUNT_ID, - formatDocsLink, - mergeAllowFromEntries, - normalizeAccountId, - patchScopedAccountConfig, - resolveAccountIdForConfigure, - setTopLevelChannelDmPolicyWithAllowFrom, -} from "openclaw/plugin-sdk/bluebubbles"; -import { - listBlueBubblesAccountIds, - resolveBlueBubblesAccount, - resolveDefaultBlueBubblesAccountId, -} from "./accounts.js"; -import { applyBlueBubblesConnectionConfig } from "./config-apply.js"; -import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js"; -import { parseBlueBubblesAllowTarget } from "./targets.js"; -import { normalizeBlueBubblesServerUrl } from "./types.js"; - -const channel = "bluebubbles" as const; - -function setBlueBubblesDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel: "bluebubbles", - dmPolicy, - }); -} - -function setBlueBubblesAllowFrom( - cfg: OpenClawConfig, - accountId: string, - allowFrom: string[], -): OpenClawConfig { - return patchScopedAccountConfig({ - cfg, - channelKey: channel, - accountId, - patch: { allowFrom }, - ensureChannelEnabled: false, - ensureAccountEnabled: false, - }); -} - -function parseBlueBubblesAllowFromInput(raw: string): string[] { - return raw - .split(/[\n,]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); -} - -async function promptBlueBubblesAllowFrom(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - const accountId = - params.accountId && normalizeAccountId(params.accountId) - ? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID) - : resolveDefaultBlueBubblesAccountId(params.cfg); - const resolved = resolveBlueBubblesAccount({ cfg: params.cfg, accountId }); - const existing = resolved.config.allowFrom ?? []; - await params.prompter.note( - [ - "Allowlist BlueBubbles DMs by handle or chat target.", - "Examples:", - "- +15555550123", - "- user@example.com", - "- chat_id:123", - "- chat_guid:iMessage;-;+15555550123", - "Multiple entries: comma- or newline-separated.", - `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, - ].join("\n"), - "BlueBubbles allowlist", - ); - const entry = await params.prompter.text({ - message: "BlueBubbles allowFrom (handle or chat_id)", - placeholder: "+15555550123, user@example.com, chat_id:123", - initialValue: existing[0] ? String(existing[0]) : undefined, - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) { - return "Required"; - } - const parts = parseBlueBubblesAllowFromInput(raw); - for (const part of parts) { - if (part === "*") { - continue; - } - const parsed = parseBlueBubblesAllowTarget(part); - if (parsed.kind === "handle" && !parsed.handle) { - return `Invalid entry: ${part}`; - } - } - return undefined; - }, - }); - const parts = parseBlueBubblesAllowFromInput(String(entry)); - const unique = mergeAllowFromEntries(undefined, parts); - return setBlueBubblesAllowFrom(params.cfg, accountId, unique); -} - -const dmPolicy: ChannelOnboardingDmPolicy = { - label: "BlueBubbles", - channel, - policyKey: "channels.bluebubbles.dmPolicy", - allowFromKey: "channels.bluebubbles.allowFrom", - getCurrent: (cfg) => cfg.channels?.bluebubbles?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => setBlueBubblesDmPolicy(cfg, policy), - promptAllowFrom: promptBlueBubblesAllowFrom, -}; - -export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const configured = listBlueBubblesAccountIds(cfg).some((accountId) => { - const account = resolveBlueBubblesAccount({ cfg, accountId }); - return account.configured; - }); - return { - channel, - configured, - statusLines: [`BlueBubbles: ${configured ? "configured" : "needs setup"}`], - selectionHint: configured ? "configured" : "iMessage via BlueBubbles app", - quickstartScore: configured ? 1 : 0, - }; - }, - configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { - const defaultAccountId = resolveDefaultBlueBubblesAccountId(cfg); - const accountId = await resolveAccountIdForConfigure({ - cfg, - prompter, - label: "BlueBubbles", - accountOverride: accountOverrides.bluebubbles, - shouldPromptAccountIds, - listAccountIds: listBlueBubblesAccountIds, - defaultAccountId, - }); - - let next = cfg; - const resolvedAccount = resolveBlueBubblesAccount({ cfg: next, accountId }); - const validateServerUrlInput = (value: unknown): string | undefined => { - const trimmed = String(value ?? "").trim(); - if (!trimmed) { - return "Required"; - } - try { - const normalized = normalizeBlueBubblesServerUrl(trimmed); - new URL(normalized); - return undefined; - } catch { - return "Invalid URL format"; - } - }; - const promptServerUrl = async (initialValue?: string): Promise => { - const entered = await prompter.text({ - message: "BlueBubbles server URL", - placeholder: "http://192.168.1.100:1234", - initialValue, - validate: validateServerUrlInput, - }); - return String(entered).trim(); - }; - - // Prompt for server URL - let serverUrl = resolvedAccount.config.serverUrl?.trim(); - if (!serverUrl) { - await prompter.note( - [ - "Enter the BlueBubbles server URL (e.g., http://192.168.1.100:1234).", - "Find this in the BlueBubbles Server app under Connection.", - `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, - ].join("\n"), - "BlueBubbles server URL", - ); - serverUrl = await promptServerUrl(); - } else { - const keepUrl = await prompter.confirm({ - message: `BlueBubbles server URL already set (${serverUrl}). Keep it?`, - initialValue: true, - }); - if (!keepUrl) { - serverUrl = await promptServerUrl(serverUrl); - } - } - - // Prompt for password - const existingPassword = resolvedAccount.config.password; - const existingPasswordText = normalizeSecretInputString(existingPassword); - const hasConfiguredPassword = hasConfiguredSecretInput(existingPassword); - let password: unknown = existingPasswordText; - if (!hasConfiguredPassword) { - await prompter.note( - [ - "Enter the BlueBubbles server password.", - "Find this in the BlueBubbles Server app under Settings.", - ].join("\n"), - "BlueBubbles password", - ); - const entered = await prompter.text({ - message: "BlueBubbles password", - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - password = String(entered).trim(); - } else { - const keepPassword = await prompter.confirm({ - message: "BlueBubbles password already set. Keep it?", - initialValue: true, - }); - if (!keepPassword) { - const entered = await prompter.text({ - message: "BlueBubbles password", - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - password = String(entered).trim(); - } else if (!existingPasswordText) { - password = existingPassword; - } - } - - // Prompt for webhook path (optional) - const existingWebhookPath = resolvedAccount.config.webhookPath?.trim(); - const wantsWebhook = await prompter.confirm({ - message: "Configure a custom webhook path? (default: /bluebubbles-webhook)", - initialValue: Boolean(existingWebhookPath && existingWebhookPath !== "/bluebubbles-webhook"), - }); - let webhookPath = "/bluebubbles-webhook"; - if (wantsWebhook) { - const entered = await prompter.text({ - message: "Webhook path", - placeholder: "/bluebubbles-webhook", - initialValue: existingWebhookPath || "/bluebubbles-webhook", - validate: (value) => { - const trimmed = String(value ?? "").trim(); - if (!trimmed) { - return "Required"; - } - if (!trimmed.startsWith("/")) { - return "Path must start with /"; - } - return undefined; - }, - }); - webhookPath = String(entered).trim(); - } - - // Apply config - next = applyBlueBubblesConnectionConfig({ - cfg: next, - accountId, - patch: { - serverUrl, - password, - webhookPath, - }, - accountEnabled: "preserve-or-true", - }); - - await prompter.note( - [ - "Configure the webhook URL in BlueBubbles Server:", - "1. Open BlueBubbles Server → Settings → Webhooks", - "2. Add your OpenClaw gateway URL + webhook path", - " Example: https://your-gateway-host:3000/bluebubbles-webhook", - "3. Enable the webhook and save", - "", - `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, - ].join("\n"), - "BlueBubbles next steps", - ); - - return { cfg: next, accountId }; - }, - dmPolicy, - disable: (cfg) => ({ - ...cfg, - channels: { - ...cfg.channels, - bluebubbles: { ...cfg.channels?.bluebubbles, enabled: false }, - }, - }), -}; diff --git a/extensions/bluebubbles/src/setup-surface.test.ts b/extensions/bluebubbles/src/setup-surface.test.ts new file mode 100644 index 00000000000..bc9c93735b7 --- /dev/null +++ b/extensions/bluebubbles/src/setup-surface.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { resolveBlueBubblesAccount } from "./accounts.js"; +import { DEFAULT_WEBHOOK_PATH } from "./monitor-shared.js"; + +async function createBlueBubblesConfigureAdapter() { + const { blueBubblesSetupAdapter, blueBubblesSetupWizard } = await import("./setup-surface.js"); + const plugin = { + id: "bluebubbles", + meta: { + id: "bluebubbles", + label: "BlueBubbles", + selectionLabel: "BlueBubbles", + docsPath: "/channels/bluebubbles", + blurb: "iMessage via BlueBubbles", + }, + config: { + listAccountIds: () => [DEFAULT_ACCOUNT_ID], + defaultAccountId: () => DEFAULT_ACCOUNT_ID, + resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg, accountId }), + resolveAllowFrom: ({ cfg, accountId }: { cfg: unknown; accountId: string }) => + resolveBlueBubblesAccount({ + cfg: cfg as Parameters[0]["cfg"], + accountId, + }).config.allowFrom ?? [], + }, + setup: blueBubblesSetupAdapter, + } as Parameters[0]["plugin"]; + return buildChannelOnboardingAdapterFromSetupWizard({ + plugin, + wizard: blueBubblesSetupWizard, + }); +} + +describe("bluebubbles setup surface", () => { + it("preserves existing password SecretRef and keeps default webhook path", async () => { + const adapter = await createBlueBubblesConfigureAdapter(); + type ConfigureContext = Parameters>[0]; + const passwordRef = { source: "env", provider: "default", id: "BLUEBUBBLES_PASSWORD" }; + const confirm = vi + .fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + const text = vi.fn(); + const note = vi.fn(); + + const prompter = { confirm, text, note } as unknown as WizardPrompter; + const context = { + cfg: { + channels: { + bluebubbles: { + enabled: true, + serverUrl: "http://127.0.0.1:1234", + password: passwordRef, + }, + }, + }, + prompter, + runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"], + forceAllowFrom: false, + accountOverrides: {}, + shouldPromptAccountIds: false, + } satisfies ConfigureContext; + + const result = await adapter.configure(context); + + expect(result.cfg.channels?.bluebubbles?.password).toEqual(passwordRef); + expect(result.cfg.channels?.bluebubbles?.webhookPath).toBe(DEFAULT_WEBHOOK_PATH); + expect(text).not.toHaveBeenCalled(); + }); + + it("applies a custom webhook path when requested", async () => { + const adapter = await createBlueBubblesConfigureAdapter(); + type ConfigureContext = Parameters>[0]; + const confirm = vi + .fn() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + const text = vi.fn().mockResolvedValueOnce("/custom-bluebubbles"); + const note = vi.fn(); + + const prompter = { confirm, text, note } as unknown as WizardPrompter; + const context = { + cfg: { + channels: { + bluebubbles: { + enabled: true, + serverUrl: "http://127.0.0.1:1234", + password: "secret", + }, + }, + }, + prompter, + runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"], + forceAllowFrom: false, + accountOverrides: {}, + shouldPromptAccountIds: false, + } satisfies ConfigureContext; + + const result = await adapter.configure(context); + + expect(result.cfg.channels?.bluebubbles?.webhookPath).toBe("/custom-bluebubbles"); + expect(text).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Webhook path", + placeholder: DEFAULT_WEBHOOK_PATH, + }), + ); + }); + + it("validates server URLs before accepting input", async () => { + const adapter = await createBlueBubblesConfigureAdapter(); + type ConfigureContext = Parameters>[0]; + const confirm = vi.fn().mockResolvedValueOnce(false); + const text = vi.fn().mockResolvedValueOnce("127.0.0.1:1234").mockResolvedValueOnce("secret"); + const note = vi.fn(); + + const prompter = { confirm, text, note } as unknown as WizardPrompter; + const context = { + cfg: { channels: { bluebubbles: {} } }, + prompter, + runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"], + forceAllowFrom: false, + accountOverrides: {}, + shouldPromptAccountIds: false, + } satisfies ConfigureContext; + + await adapter.configure(context); + + const serverUrlPrompt = text.mock.calls[0]?.[0] as { + validate?: (value: string) => string | undefined; + }; + expect(serverUrlPrompt.validate?.("bad url")).toBe("Invalid URL format"); + expect(serverUrlPrompt.validate?.("127.0.0.1:1234")).toBeUndefined(); + }); + + it("disables the channel through the setup wizard", async () => { + const { blueBubblesSetupWizard } = await import("./setup-surface.js"); + const next = blueBubblesSetupWizard.disable?.({ + channels: { + bluebubbles: { + enabled: true, + serverUrl: "http://127.0.0.1:1234", + }, + }, + }); + + expect(next?.channels?.bluebubbles?.enabled).toBe(false); + }); +}); diff --git a/extensions/bluebubbles/src/setup-surface.ts b/extensions/bluebubbles/src/setup-surface.ts new file mode 100644 index 00000000000..0cb23998663 --- /dev/null +++ b/extensions/bluebubbles/src/setup-surface.ts @@ -0,0 +1,385 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + mergeAllowFromEntries, + resolveOnboardingAccountId, + setTopLevelChannelDmPolicyWithAllowFrom, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, + patchScopedAccountConfig, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DmPolicy } from "../../../src/config/types.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { + listBlueBubblesAccountIds, + resolveBlueBubblesAccount, + resolveDefaultBlueBubblesAccountId, +} from "./accounts.js"; +import { applyBlueBubblesConnectionConfig } from "./config-apply.js"; +import { DEFAULT_WEBHOOK_PATH } from "./monitor-shared.js"; +import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js"; +import { parseBlueBubblesAllowTarget } from "./targets.js"; +import { normalizeBlueBubblesServerUrl } from "./types.js"; + +const channel = "bluebubbles" as const; +const CONFIGURE_CUSTOM_WEBHOOK_FLAG = "__bluebubblesConfigureCustomWebhookPath"; + +function setBlueBubblesDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { + return setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy, + }); +} + +function setBlueBubblesAllowFrom( + cfg: OpenClawConfig, + accountId: string, + allowFrom: string[], +): OpenClawConfig { + return patchScopedAccountConfig({ + cfg, + channelKey: channel, + accountId, + patch: { allowFrom }, + ensureChannelEnabled: false, + ensureAccountEnabled: false, + }); +} + +function parseBlueBubblesAllowFromInput(raw: string): string[] { + return raw + .split(/[\n,]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function validateBlueBubblesAllowFromEntry(value: string): string | null { + try { + if (value === "*") { + return value; + } + const parsed = parseBlueBubblesAllowTarget(value); + if (parsed.kind === "handle" && !parsed.handle) { + return null; + } + return value.trim() || null; + } catch { + return null; + } +} + +async function promptBlueBubblesAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultBlueBubblesAccountId(params.cfg), + }); + const resolved = resolveBlueBubblesAccount({ cfg: params.cfg, accountId }); + const existing = resolved.config.allowFrom ?? []; + await params.prompter.note( + [ + "Allowlist BlueBubbles DMs by handle or chat target.", + "Examples:", + "- +15555550123", + "- user@example.com", + "- chat_id:123", + "- chat_guid:iMessage;-;+15555550123", + "Multiple entries: comma- or newline-separated.", + `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, + ].join("\n"), + "BlueBubbles allowlist", + ); + const entry = await params.prompter.text({ + message: "BlueBubbles allowFrom (handle or chat_id)", + placeholder: "+15555550123, user@example.com, chat_id:123", + initialValue: existing[0] ? String(existing[0]) : undefined, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + const parts = parseBlueBubblesAllowFromInput(raw); + for (const part of parts) { + if (!validateBlueBubblesAllowFromEntry(part)) { + return `Invalid entry: ${part}`; + } + } + return undefined; + }, + }); + const parts = parseBlueBubblesAllowFromInput(String(entry)); + const unique = mergeAllowFromEntries(undefined, parts); + return setBlueBubblesAllowFrom(params.cfg, accountId, unique); +} + +function validateBlueBubblesServerUrlInput(value: unknown): string | undefined { + const trimmed = String(value ?? "").trim(); + if (!trimmed) { + return "Required"; + } + try { + const normalized = normalizeBlueBubblesServerUrl(trimmed); + new URL(normalized); + return undefined; + } catch { + return "Invalid URL format"; + } +} + +function applyBlueBubblesSetupPatch( + cfg: OpenClawConfig, + accountId: string, + patch: { + serverUrl?: string; + password?: unknown; + webhookPath?: string; + }, +): OpenClawConfig { + return applyBlueBubblesConnectionConfig({ + cfg, + accountId, + patch, + onlyDefinedFields: true, + accountEnabled: "preserve-or-true", + }); +} + +function resolveBlueBubblesServerUrl(cfg: OpenClawConfig, accountId: string): string | undefined { + return resolveBlueBubblesAccount({ cfg, accountId }).config.serverUrl?.trim() || undefined; +} + +function resolveBlueBubblesWebhookPath(cfg: OpenClawConfig, accountId: string): string | undefined { + return resolveBlueBubblesAccount({ cfg, accountId }).config.webhookPath?.trim() || undefined; +} + +function validateBlueBubblesWebhookPath(value: string): string | undefined { + const trimmed = String(value ?? "").trim(); + if (!trimmed) { + return "Required"; + } + if (!trimmed.startsWith("/")) { + return "Path must start with /"; + } + return undefined; +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "BlueBubbles", + channel, + policyKey: "channels.bluebubbles.dmPolicy", + allowFromKey: "channels.bluebubbles.allowFrom", + getCurrent: (cfg) => cfg.channels?.bluebubbles?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setBlueBubblesDmPolicy(cfg, policy), + promptAllowFrom: promptBlueBubblesAllowFrom, +}; + +export const blueBubblesSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ input }) => { + if (!input.httpUrl && !input.password) { + return "BlueBubbles requires --http-url and --password."; + } + if (!input.httpUrl) { + return "BlueBubbles requires --http-url."; + } + if (!input.password) { + return "BlueBubbles requires --password."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + return applyBlueBubblesConnectionConfig({ + cfg: next, + accountId, + patch: { + serverUrl: input.httpUrl, + password: input.password, + webhookPath: input.webhookPath, + }, + onlyDefinedFields: true, + }); + }, +}; + +export const blueBubblesSetupWizard: ChannelSetupWizard = { + channel, + stepOrder: "text-first", + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + configuredHint: "configured", + unconfiguredHint: "iMessage via BlueBubbles app", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => + listBlueBubblesAccountIds(cfg).some((accountId) => { + const account = resolveBlueBubblesAccount({ cfg, accountId }); + return account.configured; + }), + resolveStatusLines: ({ configured }) => [ + `BlueBubbles: ${configured ? "configured" : "needs setup"}`, + ], + resolveSelectionHint: ({ configured }) => + configured ? "configured" : "iMessage via BlueBubbles app", + }, + prepare: async ({ cfg, accountId, prompter, credentialValues }) => { + const existingWebhookPath = resolveBlueBubblesWebhookPath(cfg, accountId); + const wantsCustomWebhook = await prompter.confirm({ + message: `Configure a custom webhook path? (default: ${DEFAULT_WEBHOOK_PATH})`, + initialValue: Boolean(existingWebhookPath && existingWebhookPath !== DEFAULT_WEBHOOK_PATH), + }); + return { + cfg: wantsCustomWebhook + ? cfg + : applyBlueBubblesSetupPatch(cfg, accountId, { webhookPath: DEFAULT_WEBHOOK_PATH }), + credentialValues: { + ...credentialValues, + [CONFIGURE_CUSTOM_WEBHOOK_FLAG]: wantsCustomWebhook ? "1" : "0", + }, + }; + }, + credentials: [ + { + inputKey: "password", + providerHint: channel, + credentialLabel: "server password", + helpTitle: "BlueBubbles password", + helpLines: [ + "Enter the BlueBubbles server password.", + "Find this in the BlueBubbles Server app under Settings.", + ], + envPrompt: "", + keepPrompt: "BlueBubbles password already set. Keep it?", + inputPrompt: "BlueBubbles password", + inspect: ({ cfg, accountId }) => { + const existingPassword = resolveBlueBubblesAccount({ cfg, accountId }).config.password; + return { + accountConfigured: resolveBlueBubblesAccount({ cfg, accountId }).configured, + hasConfiguredValue: hasConfiguredSecretInput(existingPassword), + resolvedValue: normalizeSecretInputString(existingPassword) ?? undefined, + }; + }, + applySet: async ({ cfg, accountId, value }) => + applyBlueBubblesSetupPatch(cfg, accountId, { + password: value, + }), + }, + ], + textInputs: [ + { + inputKey: "httpUrl", + message: "BlueBubbles server URL", + placeholder: "http://192.168.1.100:1234", + helpTitle: "BlueBubbles server URL", + helpLines: [ + "Enter the BlueBubbles server URL (e.g., http://192.168.1.100:1234).", + "Find this in the BlueBubbles Server app under Connection.", + `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, + ], + currentValue: ({ cfg, accountId }) => resolveBlueBubblesServerUrl(cfg, accountId), + validate: ({ value }) => validateBlueBubblesServerUrlInput(value), + normalizeValue: ({ value }) => String(value).trim(), + applySet: async ({ cfg, accountId, value }) => + applyBlueBubblesSetupPatch(cfg, accountId, { + serverUrl: value, + }), + }, + { + inputKey: "webhookPath", + message: "Webhook path", + placeholder: DEFAULT_WEBHOOK_PATH, + currentValue: ({ cfg, accountId }) => { + const value = resolveBlueBubblesWebhookPath(cfg, accountId); + return value && value !== DEFAULT_WEBHOOK_PATH ? value : undefined; + }, + shouldPrompt: ({ credentialValues }) => + credentialValues[CONFIGURE_CUSTOM_WEBHOOK_FLAG] === "1", + validate: ({ value }) => validateBlueBubblesWebhookPath(value), + normalizeValue: ({ value }) => String(value).trim(), + applySet: async ({ cfg, accountId, value }) => + applyBlueBubblesSetupPatch(cfg, accountId, { + webhookPath: value, + }), + }, + ], + completionNote: { + title: "BlueBubbles next steps", + lines: [ + "Configure the webhook URL in BlueBubbles Server:", + "1. Open BlueBubbles Server -> Settings -> Webhooks", + "2. Add your OpenClaw gateway URL + webhook path", + ` Example: https://your-gateway-host:3000${DEFAULT_WEBHOOK_PATH}`, + "3. Enable the webhook and save", + "", + `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, + ], + }, + dmPolicy, + allowFrom: { + helpTitle: "BlueBubbles allowlist", + helpLines: [ + "Allowlist BlueBubbles DMs by handle or chat target.", + "Examples:", + "- +15555550123", + "- user@example.com", + "- chat_id:123", + "- chat_guid:iMessage;-;+15555550123", + "Multiple entries: comma- or newline-separated.", + `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, + ], + message: "BlueBubbles allowFrom (handle or chat_id)", + placeholder: "+15555550123, user@example.com, chat_id:123", + invalidWithoutCredentialNote: + "Use a BlueBubbles handle or chat target like +15555550123 or chat_id:123.", + parseInputs: parseBlueBubblesAllowFromInput, + parseId: (raw) => validateBlueBubblesAllowFromEntry(raw), + resolveEntries: async ({ entries }) => + entries.map((entry) => ({ + input: entry, + resolved: Boolean(validateBlueBubblesAllowFromEntry(entry)), + id: validateBlueBubblesAllowFromEntry(entry), + })), + apply: async ({ cfg, accountId, allowFrom }) => + setBlueBubblesAllowFrom(cfg, accountId, allowFrom), + }, + disable: (cfg) => ({ + ...cfg, + channels: { + ...cfg.channels, + bluebubbles: { + ...cfg.channels?.bluebubbles, + enabled: false, + }, + }, + }), +}; diff --git a/src/plugin-sdk/bluebubbles.ts b/src/plugin-sdk/bluebubbles.ts index 02619206fce..dff21c19bd7 100644 --- a/src/plugin-sdk/bluebubbles.ts +++ b/src/plugin-sdk/bluebubbles.ts @@ -31,10 +31,6 @@ export { } from "../channels/plugins/group-mentions.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; export { addWildcardAllowFrom, mergeAllowFromEntries, From c455cccd3d7b76215bb303b3a01307b030e96e1f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 17:33:51 -0700 Subject: [PATCH 067/943] refactor: move nextcloud talk to setup wizard --- extensions/nextcloud-talk/src/channel.ts | 90 +--- extensions/nextcloud-talk/src/onboarding.ts | 302 ------------- .../nextcloud-talk/src/setup-surface.test.ts | 53 +++ .../nextcloud-talk/src/setup-surface.ts | 406 ++++++++++++++++++ src/plugin-sdk/nextcloud-talk.ts | 4 - 5 files changed, 462 insertions(+), 393 deletions(-) delete mode 100644 extensions/nextcloud-talk/src/onboarding.ts create mode 100644 extensions/nextcloud-talk/src/setup-surface.test.ts create mode 100644 extensions/nextcloud-talk/src/setup-surface.ts diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 473299b74e0..b6a2c2ad5ca 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -7,18 +7,15 @@ import { mapAllowFromEntries, } from "openclaw/plugin-sdk/compat"; import { - applyAccountNameToChannelSection, buildBaseChannelStatusSummary, buildChannelConfigSchema, buildRuntimeAccountStatusSnapshot, clearAccountEntryFields, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, - normalizeAccountId, setAccountEnabledInConfigSection, type ChannelPlugin, type OpenClawConfig, - type ChannelSetupInput, } from "openclaw/plugin-sdk/nextcloud-talk"; import { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js"; import { @@ -33,10 +30,10 @@ import { looksLikeNextcloudTalkTargetId, normalizeNextcloudTalkMessagingTarget, } from "./normalize.js"; -import { nextcloudTalkOnboardingAdapter } from "./onboarding.js"; import { resolveNextcloudTalkGroupToolPolicy } from "./policy.js"; import { getNextcloudTalkRuntime } from "./runtime.js"; import { sendMessageNextcloudTalk } from "./send.js"; +import { nextcloudTalkSetupAdapter, nextcloudTalkSetupWizard } from "./setup-surface.js"; import type { CoreConfig } from "./types.js"; const meta = { @@ -51,17 +48,10 @@ const meta = { quickstartAllowFrom: true, }; -type NextcloudSetupInput = ChannelSetupInput & { - baseUrl?: string; - secret?: string; - secretFile?: string; - useEnv?: boolean; -}; - export const nextcloudTalkPlugin: ChannelPlugin = { id: "nextcloud-talk", meta, - onboarding: nextcloudTalkOnboardingAdapter, + setupWizard: nextcloudTalkSetupWizard, pairing: { idLabel: "nextcloudUserId", normalizeAllowEntry: (entry) => @@ -190,81 +180,7 @@ export const nextcloudTalkPlugin: ChannelPlugin = hint: "", }, }, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "nextcloud-talk", - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - const setupInput = input as NextcloudSetupInput; - if (setupInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account."; - } - if (!setupInput.useEnv && !setupInput.secret && !setupInput.secretFile) { - return "Nextcloud Talk requires bot secret or --secret-file (or --use-env)."; - } - if (!setupInput.baseUrl) { - return "Nextcloud Talk requires --base-url."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const setupInput = input as NextcloudSetupInput; - const namedConfig = applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "nextcloud-talk", - accountId, - name: setupInput.name, - }); - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...namedConfig, - channels: { - ...namedConfig.channels, - "nextcloud-talk": { - ...namedConfig.channels?.["nextcloud-talk"], - enabled: true, - baseUrl: setupInput.baseUrl, - ...(setupInput.useEnv - ? {} - : setupInput.secretFile - ? { botSecretFile: setupInput.secretFile } - : setupInput.secret - ? { botSecret: setupInput.secret } - : {}), - }, - }, - } as OpenClawConfig; - } - return { - ...namedConfig, - channels: { - ...namedConfig.channels, - "nextcloud-talk": { - ...namedConfig.channels?.["nextcloud-talk"], - enabled: true, - accounts: { - ...namedConfig.channels?.["nextcloud-talk"]?.accounts, - [accountId]: { - ...namedConfig.channels?.["nextcloud-talk"]?.accounts?.[accountId], - enabled: true, - baseUrl: setupInput.baseUrl, - ...(setupInput.secretFile - ? { botSecretFile: setupInput.secretFile } - : setupInput.secret - ? { botSecret: setupInput.secret } - : {}), - }, - }, - }, - }, - } as OpenClawConfig; - }, - }, + setup: nextcloudTalkSetupAdapter, outbound: { deliveryMode: "direct", chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit), diff --git a/extensions/nextcloud-talk/src/onboarding.ts b/extensions/nextcloud-talk/src/onboarding.ts deleted file mode 100644 index 7b1a8b11d28..00000000000 --- a/extensions/nextcloud-talk/src/onboarding.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { - formatDocsLink, - hasConfiguredSecretInput, - mapAllowFromEntries, - mergeAllowFromEntries, - patchScopedAccountConfig, - runSingleChannelSecretStep, - resolveAccountIdForConfigure, - DEFAULT_ACCOUNT_ID, - normalizeAccountId, - setTopLevelChannelDmPolicyWithAllowFrom, - type ChannelOnboardingAdapter, - type ChannelOnboardingDmPolicy, - type OpenClawConfig, - type WizardPrompter, -} from "openclaw/plugin-sdk/nextcloud-talk"; -import { - listNextcloudTalkAccountIds, - resolveDefaultNextcloudTalkAccountId, - resolveNextcloudTalkAccount, -} from "./accounts.js"; -import type { CoreConfig, DmPolicy } from "./types.js"; - -const channel = "nextcloud-talk" as const; - -function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel: "nextcloud-talk", - dmPolicy, - getAllowFrom: (inputCfg) => - mapAllowFromEntries(inputCfg.channels?.["nextcloud-talk"]?.allowFrom), - }) as CoreConfig; -} - -function setNextcloudTalkAccountConfig( - cfg: CoreConfig, - accountId: string, - updates: Record, -): CoreConfig { - return patchScopedAccountConfig({ - cfg, - channelKey: channel, - accountId, - patch: updates, - }) as CoreConfig; -} - -async function noteNextcloudTalkSecretHelp(prompter: WizardPrompter): Promise { - await prompter.note( - [ - "1) SSH into your Nextcloud server", - '2) Run: ./occ talk:bot:install "OpenClaw" "" "" --feature reaction', - "3) Copy the shared secret you used in the command", - "4) Enable the bot in your Nextcloud Talk room settings", - "Tip: you can also set NEXTCLOUD_TALK_BOT_SECRET in your env.", - `Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`, - ].join("\n"), - "Nextcloud Talk bot setup", - ); -} - -async function noteNextcloudTalkUserIdHelp(prompter: WizardPrompter): Promise { - await prompter.note( - [ - "1) Check the Nextcloud admin panel for user IDs", - "2) Or look at the webhook payload logs when someone messages", - "3) User IDs are typically lowercase usernames in Nextcloud", - `Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`, - ].join("\n"), - "Nextcloud Talk user id", - ); -} - -async function promptNextcloudTalkAllowFrom(params: { - cfg: CoreConfig; - prompter: WizardPrompter; - accountId: string; -}): Promise { - const { cfg, prompter, accountId } = params; - const resolved = resolveNextcloudTalkAccount({ cfg, accountId }); - const existingAllowFrom = resolved.config.allowFrom ?? []; - await noteNextcloudTalkUserIdHelp(prompter); - - const parseInput = (value: string) => - value - .split(/[\n,;]+/g) - .map((entry) => entry.trim().toLowerCase()) - .filter(Boolean); - - let resolvedIds: string[] = []; - while (resolvedIds.length === 0) { - const entry = await prompter.text({ - message: "Nextcloud Talk allowFrom (user id)", - placeholder: "username", - initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - resolvedIds = parseInput(String(entry)); - if (resolvedIds.length === 0) { - await prompter.note("Please enter at least one valid user ID.", "Nextcloud Talk allowlist"); - } - } - - const merged = [ - ...existingAllowFrom.map((item) => String(item).trim().toLowerCase()).filter(Boolean), - ...resolvedIds, - ]; - const unique = mergeAllowFromEntries(undefined, merged); - - return setNextcloudTalkAccountConfig(cfg, accountId, { - dmPolicy: "allowlist", - allowFrom: unique, - }); -} - -async function promptNextcloudTalkAllowFromForAccount(params: { - cfg: CoreConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - const accountId = - params.accountId && normalizeAccountId(params.accountId) - ? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID) - : resolveDefaultNextcloudTalkAccountId(params.cfg); - return promptNextcloudTalkAllowFrom({ - cfg: params.cfg, - prompter: params.prompter, - accountId, - }); -} - -const dmPolicy: ChannelOnboardingDmPolicy = { - label: "Nextcloud Talk", - channel, - policyKey: "channels.nextcloud-talk.dmPolicy", - allowFromKey: "channels.nextcloud-talk.allowFrom", - getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy), - promptAllowFrom: promptNextcloudTalkAllowFromForAccount as (params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string | undefined; - }) => Promise, -}; - -export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const configured = listNextcloudTalkAccountIds(cfg as CoreConfig).some((accountId) => { - const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }); - return Boolean(account.secret && account.baseUrl); - }); - return { - channel, - configured, - statusLines: [`Nextcloud Talk: ${configured ? "configured" : "needs setup"}`], - selectionHint: configured ? "configured" : "self-hosted chat", - quickstartScore: configured ? 1 : 5, - }; - }, - configure: async ({ - cfg, - prompter, - accountOverrides, - shouldPromptAccountIds, - forceAllowFrom, - }) => { - const defaultAccountId = resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig); - const accountId = await resolveAccountIdForConfigure({ - cfg, - prompter, - label: "Nextcloud Talk", - accountOverride: accountOverrides["nextcloud-talk"], - shouldPromptAccountIds, - listAccountIds: listNextcloudTalkAccountIds as (cfg: OpenClawConfig) => string[], - defaultAccountId, - }); - - let next = cfg as CoreConfig; - const resolvedAccount = resolveNextcloudTalkAccount({ - cfg: next, - accountId, - }); - const accountConfigured = Boolean(resolvedAccount.secret && resolvedAccount.baseUrl); - const allowEnv = accountId === DEFAULT_ACCOUNT_ID; - const hasConfigSecret = Boolean( - hasConfiguredSecretInput(resolvedAccount.config.botSecret) || - resolvedAccount.config.botSecretFile, - ); - - let baseUrl = resolvedAccount.baseUrl; - if (!baseUrl) { - baseUrl = String( - await prompter.text({ - message: "Enter Nextcloud instance URL (e.g., https://cloud.example.com)", - validate: (value) => { - const v = String(value ?? "").trim(); - if (!v) { - return "Required"; - } - if (!v.startsWith("http://") && !v.startsWith("https://")) { - return "URL must start with http:// or https://"; - } - return undefined; - }, - }), - ).trim(); - } - - const secretStep = await runSingleChannelSecretStep({ - cfg: next, - prompter, - providerHint: "nextcloud-talk", - credentialLabel: "bot secret", - accountConfigured, - hasConfigToken: hasConfigSecret, - allowEnv, - envValue: process.env.NEXTCLOUD_TALK_BOT_SECRET, - envPrompt: "NEXTCLOUD_TALK_BOT_SECRET detected. Use env var?", - keepPrompt: "Nextcloud Talk bot secret already configured. Keep it?", - inputPrompt: "Enter Nextcloud Talk bot secret", - preferredEnvVar: "NEXTCLOUD_TALK_BOT_SECRET", - onMissingConfigured: async () => await noteNextcloudTalkSecretHelp(prompter), - applyUseEnv: async (cfg) => - setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, { - baseUrl, - }), - applySet: async (cfg, value) => - setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, { - baseUrl, - botSecret: value, - }), - }); - next = secretStep.cfg as CoreConfig; - - if (secretStep.action === "keep" && baseUrl !== resolvedAccount.baseUrl) { - next = setNextcloudTalkAccountConfig(next, accountId, { - baseUrl, - }); - } - - const existingApiUser = resolvedAccount.config.apiUser?.trim(); - const existingApiPasswordConfigured = Boolean( - hasConfiguredSecretInput(resolvedAccount.config.apiPassword) || - resolvedAccount.config.apiPasswordFile, - ); - const configureApiCredentials = await prompter.confirm({ - message: "Configure optional Nextcloud Talk API credentials for room lookups?", - initialValue: Boolean(existingApiUser && existingApiPasswordConfigured), - }); - if (configureApiCredentials) { - const apiUser = String( - await prompter.text({ - message: "Nextcloud Talk API user", - initialValue: existingApiUser, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }), - ).trim(); - const apiPasswordStep = await runSingleChannelSecretStep({ - cfg: next, - prompter, - providerHint: "nextcloud-talk-api", - credentialLabel: "API password", - accountConfigured: Boolean(existingApiUser && existingApiPasswordConfigured), - hasConfigToken: existingApiPasswordConfigured, - allowEnv: false, - envPrompt: "", - keepPrompt: "Nextcloud Talk API password already configured. Keep it?", - inputPrompt: "Enter Nextcloud Talk API password", - preferredEnvVar: "NEXTCLOUD_TALK_API_PASSWORD", - applySet: async (cfg, value) => - setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, { - apiUser, - apiPassword: value, - }), - }); - next = - apiPasswordStep.action === "keep" - ? setNextcloudTalkAccountConfig(next, accountId, { apiUser }) - : (apiPasswordStep.cfg as CoreConfig); - } - - if (forceAllowFrom) { - next = await promptNextcloudTalkAllowFrom({ - cfg: next, - prompter, - accountId, - }); - } - - return { cfg: next, accountId }; - }, - dmPolicy, - disable: (cfg) => ({ - ...cfg, - channels: { - ...cfg.channels, - "nextcloud-talk": { ...cfg.channels?.["nextcloud-talk"], enabled: false }, - }, - }), -}; diff --git a/extensions/nextcloud-talk/src/setup-surface.test.ts b/extensions/nextcloud-talk/src/setup-surface.test.ts new file mode 100644 index 00000000000..3889cc7ff8a --- /dev/null +++ b/extensions/nextcloud-talk/src/setup-surface.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { nextcloudTalkSetupAdapter, nextcloudTalkSetupWizard } from "./setup-surface.js"; + +describe("nextcloudTalk setup surface", () => { + it("clears stored bot secret fields when switching the default account to env", () => { + type ApplyAccountConfigContext = Parameters< + typeof nextcloudTalkSetupAdapter.applyAccountConfig + >[0]; + + const next = nextcloudTalkSetupAdapter.applyAccountConfig({ + cfg: { + channels: { + "nextcloud-talk": { + enabled: true, + baseUrl: "https://cloud.old.example", + botSecret: "stored-secret", + botSecretFile: "/tmp/secret.txt", + }, + }, + }, + accountId: DEFAULT_ACCOUNT_ID, + input: { + baseUrl: "https://cloud.example.com", + useEnv: true, + }, + } as unknown as ApplyAccountConfigContext); + + expect(next.channels?.["nextcloud-talk"]?.baseUrl).toBe("https://cloud.example.com"); + expect(next.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecret"); + expect(next.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecretFile"); + }); + + it("clears stored bot secret fields when the wizard switches to env", async () => { + const credential = nextcloudTalkSetupWizard.credentials[0]; + const next = await credential.applyUseEnv?.({ + cfg: { + channels: { + "nextcloud-talk": { + enabled: true, + baseUrl: "https://cloud.example.com", + botSecret: "stored-secret", + botSecretFile: "/tmp/secret.txt", + }, + }, + }, + accountId: DEFAULT_ACCOUNT_ID, + }); + + expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecret"); + expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecretFile"); + }); +}); diff --git a/extensions/nextcloud-talk/src/setup-surface.ts b/extensions/nextcloud-talk/src/setup-surface.ts new file mode 100644 index 00000000000..758ae4d3214 --- /dev/null +++ b/extensions/nextcloud-talk/src/setup-surface.ts @@ -0,0 +1,406 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + mergeAllowFromEntries, + resolveOnboardingAccountId, + setOnboardingChannelEnabled, + setTopLevelChannelDmPolicyWithAllowFrom, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + patchScopedAccountConfig, +} from "../../../src/channels/plugins/setup-helpers.js"; +import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { + listNextcloudTalkAccountIds, + resolveDefaultNextcloudTalkAccountId, + resolveNextcloudTalkAccount, +} from "./accounts.js"; +import type { CoreConfig, DmPolicy } from "./types.js"; + +const channel = "nextcloud-talk" as const; +const CONFIGURE_API_FLAG = "__nextcloudTalkConfigureApiCredentials"; + +type NextcloudSetupInput = ChannelSetupInput & { + baseUrl?: string; + secret?: string; + secretFile?: string; +}; +type NextcloudTalkSection = NonNullable["nextcloud-talk"]; + +function normalizeNextcloudTalkBaseUrl(value: string | undefined): string { + return value?.trim().replace(/\/+$/, "") ?? ""; +} + +function validateNextcloudTalkBaseUrl(value: string): string | undefined { + if (!value) { + return "Required"; + } + if (!value.startsWith("http://") && !value.startsWith("https://")) { + return "URL must start with http:// or https://"; + } + return undefined; +} + +function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { + return setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy, + }) as CoreConfig; +} + +function setNextcloudTalkAccountConfig( + cfg: CoreConfig, + accountId: string, + updates: Record, +): CoreConfig { + return patchScopedAccountConfig({ + cfg, + channelKey: channel, + accountId, + patch: updates, + }) as CoreConfig; +} + +function clearNextcloudTalkAccountFields( + cfg: CoreConfig, + accountId: string, + fields: string[], +): CoreConfig { + const section = cfg.channels?.["nextcloud-talk"]; + if (!section) { + return cfg; + } + + if (accountId === DEFAULT_ACCOUNT_ID) { + const nextSection = { ...section } as Record; + for (const field of fields) { + delete nextSection[field]; + } + return { + ...cfg, + channels: { + ...(cfg.channels ?? {}), + "nextcloud-talk": nextSection as NextcloudTalkSection, + }, + } as CoreConfig; + } + + const currentAccount = section.accounts?.[accountId]; + if (!currentAccount) { + return cfg; + } + + const nextAccount = { ...currentAccount } as Record; + for (const field of fields) { + delete nextAccount[field]; + } + return { + ...cfg, + channels: { + ...(cfg.channels ?? {}), + "nextcloud-talk": { + ...section, + accounts: { + ...section.accounts, + [accountId]: nextAccount as NonNullable[string], + }, + }, + }, + } as CoreConfig; +} + +async function promptNextcloudTalkAllowFrom(params: { + cfg: CoreConfig; + prompter: WizardPrompter; + accountId: string; +}): Promise { + const resolved = resolveNextcloudTalkAccount({ cfg: params.cfg, accountId: params.accountId }); + const existingAllowFrom = resolved.config.allowFrom ?? []; + await params.prompter.note( + [ + "1) Check the Nextcloud admin panel for user IDs", + "2) Or look at the webhook payload logs when someone messages", + "3) User IDs are typically lowercase usernames in Nextcloud", + `Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`, + ].join("\n"), + "Nextcloud Talk user id", + ); + + let resolvedIds: string[] = []; + while (resolvedIds.length === 0) { + const entry = await params.prompter.text({ + message: "Nextcloud Talk allowFrom (user id)", + placeholder: "username", + initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + resolvedIds = String(entry) + .split(/[\n,;]+/g) + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); + if (resolvedIds.length === 0) { + await params.prompter.note("Please enter at least one valid user ID.", "Nextcloud Talk"); + } + } + + return setNextcloudTalkAccountConfig(params.cfg, params.accountId, { + dmPolicy: "allowlist", + allowFrom: mergeAllowFromEntries( + existingAllowFrom.map((value) => String(value).trim().toLowerCase()), + resolvedIds, + ), + }); +} + +async function promptNextcloudTalkAllowFromForAccount(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultNextcloudTalkAccountId(params.cfg as CoreConfig), + }); + return await promptNextcloudTalkAllowFrom({ + cfg: params.cfg as CoreConfig, + prompter: params.prompter, + accountId, + }); +} + +const nextcloudTalkDmPolicy: ChannelOnboardingDmPolicy = { + label: "Nextcloud Talk", + channel, + policyKey: "channels.nextcloud-talk.dmPolicy", + allowFromKey: "channels.nextcloud-talk.allowFrom", + getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy), + promptAllowFrom: promptNextcloudTalkAllowFromForAccount, +}; + +export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + const setupInput = input as NextcloudSetupInput; + if (setupInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account."; + } + if (!setupInput.useEnv && !setupInput.secret && !setupInput.secretFile) { + return "Nextcloud Talk requires bot secret or --secret-file (or --use-env)."; + } + if (!setupInput.baseUrl) { + return "Nextcloud Talk requires --base-url."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const setupInput = input as NextcloudSetupInput; + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: setupInput.name, + }); + const next = setupInput.useEnv + ? clearNextcloudTalkAccountFields(namedConfig as CoreConfig, accountId, [ + "botSecret", + "botSecretFile", + ]) + : namedConfig; + const patch = { + baseUrl: normalizeNextcloudTalkBaseUrl(setupInput.baseUrl), + ...(setupInput.useEnv + ? {} + : setupInput.secretFile + ? { botSecretFile: setupInput.secretFile } + : setupInput.secret + ? { botSecret: setupInput.secret } + : {}), + }; + return setNextcloudTalkAccountConfig(next as CoreConfig, accountId, patch); + }, +}; + +export const nextcloudTalkSetupWizard: ChannelSetupWizard = { + channel, + stepOrder: "text-first", + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + configuredHint: "configured", + unconfiguredHint: "self-hosted chat", + configuredScore: 1, + unconfiguredScore: 5, + resolveConfigured: ({ cfg }) => + listNextcloudTalkAccountIds(cfg as CoreConfig).some((accountId) => { + const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }); + return Boolean(account.secret && account.baseUrl); + }), + }, + introNote: { + title: "Nextcloud Talk bot setup", + lines: [ + "1) SSH into your Nextcloud server", + '2) Run: ./occ talk:bot:install "OpenClaw" "" "" --feature reaction', + "3) Copy the shared secret you used in the command", + "4) Enable the bot in your Nextcloud Talk room settings", + "Tip: you can also set NEXTCLOUD_TALK_BOT_SECRET in your env.", + `Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`, + ], + shouldShow: ({ cfg, accountId }) => { + const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }); + return !account.secret || !account.baseUrl; + }, + }, + prepare: async ({ cfg, accountId, credentialValues, prompter }) => { + const resolvedAccount = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }); + const hasApiCredentials = Boolean( + resolvedAccount.config.apiUser?.trim() && + (hasConfiguredSecretInput(resolvedAccount.config.apiPassword) || + resolvedAccount.config.apiPasswordFile), + ); + const configureApiCredentials = await prompter.confirm({ + message: "Configure optional Nextcloud Talk API credentials for room lookups?", + initialValue: hasApiCredentials, + }); + if (!configureApiCredentials) { + return; + } + return { + credentialValues: { + ...credentialValues, + [CONFIGURE_API_FLAG]: "1", + }, + }; + }, + credentials: [ + { + inputKey: "token", + providerHint: channel, + credentialLabel: "bot secret", + preferredEnvVar: "NEXTCLOUD_TALK_BOT_SECRET", + envPrompt: "NEXTCLOUD_TALK_BOT_SECRET detected. Use env var?", + keepPrompt: "Nextcloud Talk bot secret already configured. Keep it?", + inputPrompt: "Enter Nextcloud Talk bot secret", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const resolvedAccount = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }); + return { + accountConfigured: Boolean(resolvedAccount.secret && resolvedAccount.baseUrl), + hasConfiguredValue: Boolean( + hasConfiguredSecretInput(resolvedAccount.config.botSecret) || + resolvedAccount.config.botSecretFile, + ), + resolvedValue: resolvedAccount.secret || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim() || undefined + : undefined, + }; + }, + applyUseEnv: async (params) => { + const resolvedAccount = resolveNextcloudTalkAccount({ + cfg: params.cfg as CoreConfig, + accountId: params.accountId, + }); + const cleared = clearNextcloudTalkAccountFields( + params.cfg as CoreConfig, + params.accountId, + ["botSecret", "botSecretFile"], + ); + return setNextcloudTalkAccountConfig(cleared, params.accountId, { + baseUrl: resolvedAccount.baseUrl, + }); + }, + applySet: async (params) => + setNextcloudTalkAccountConfig( + clearNextcloudTalkAccountFields(params.cfg as CoreConfig, params.accountId, [ + "botSecret", + "botSecretFile", + ]), + params.accountId, + { + botSecret: params.value, + }, + ), + }, + { + inputKey: "password", + providerHint: "nextcloud-talk-api", + credentialLabel: "API password", + preferredEnvVar: "NEXTCLOUD_TALK_API_PASSWORD", + envPrompt: "", + keepPrompt: "Nextcloud Talk API password already configured. Keep it?", + inputPrompt: "Enter Nextcloud Talk API password", + inspect: ({ cfg, accountId }) => { + const resolvedAccount = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }); + const apiUser = resolvedAccount.config.apiUser?.trim(); + const apiPasswordConfigured = Boolean( + hasConfiguredSecretInput(resolvedAccount.config.apiPassword) || + resolvedAccount.config.apiPasswordFile, + ); + return { + accountConfigured: Boolean(apiUser && apiPasswordConfigured), + hasConfiguredValue: apiPasswordConfigured, + }; + }, + shouldPrompt: ({ credentialValues }) => credentialValues[CONFIGURE_API_FLAG] === "1", + applySet: async (params) => + setNextcloudTalkAccountConfig( + clearNextcloudTalkAccountFields(params.cfg as CoreConfig, params.accountId, [ + "apiPassword", + "apiPasswordFile", + ]), + params.accountId, + { + apiPassword: params.value, + }, + ), + }, + ], + textInputs: [ + { + inputKey: "httpUrl", + message: "Enter Nextcloud instance URL (e.g., https://cloud.example.com)", + currentValue: ({ cfg, accountId }) => + resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }).baseUrl || undefined, + shouldPrompt: ({ currentValue }) => !currentValue, + validate: ({ value }) => validateNextcloudTalkBaseUrl(value), + normalizeValue: ({ value }) => normalizeNextcloudTalkBaseUrl(value), + applySet: async (params) => + setNextcloudTalkAccountConfig(params.cfg as CoreConfig, params.accountId, { + baseUrl: params.value, + }), + }, + { + inputKey: "userId", + message: "Nextcloud Talk API user", + currentValue: ({ cfg, accountId }) => + resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }).config.apiUser?.trim() || + undefined, + shouldPrompt: ({ credentialValues }) => credentialValues[CONFIGURE_API_FLAG] === "1", + validate: ({ value }) => (value ? undefined : "Required"), + applySet: async (params) => + setNextcloudTalkAccountConfig(params.cfg as CoreConfig, params.accountId, { + apiUser: params.value, + }), + }, + ], + dmPolicy: nextcloudTalkDmPolicy, + disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), +}; diff --git a/src/plugin-sdk/nextcloud-talk.ts b/src/plugin-sdk/nextcloud-talk.ts index 6e5c6a28b5b..7e2434914bb 100644 --- a/src/plugin-sdk/nextcloud-talk.ts +++ b/src/plugin-sdk/nextcloud-talk.ts @@ -17,10 +17,6 @@ export { } from "../channels/plugins/config-helpers.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; export { buildSingleChannelSecretPromptState, addWildcardAllowFrom, From f87e7be55e46ed4b71e0f3b610f17c7baa08604d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 17:37:36 -0700 Subject: [PATCH 068/943] CLI: restore lightweight root help and scoped status plugin preload --- src/cli/plugin-registry.ts | 37 ++++++++++++-- src/cli/program/preaction.test.ts | 4 +- src/cli/program/preaction.ts | 6 ++- src/cli/route.test.ts | 5 +- src/cli/route.ts | 7 ++- src/entry.test.ts | 26 ++++++++++ src/entry.ts | 80 ++++++++++++++++++++----------- 7 files changed, 127 insertions(+), 38 deletions(-) create mode 100644 src/entry.test.ts diff --git a/src/cli/plugin-registry.ts b/src/cli/plugin-registry.ts index 22d7ce61abb..aad181eff7f 100644 --- a/src/cli/plugin-registry.ts +++ b/src/cli/plugin-registry.ts @@ -2,14 +2,32 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; +import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; import type { PluginLogger } from "../plugins/types.js"; const log = createSubsystemLogger("plugins"); -let pluginRegistryLoaded = false; +let pluginRegistryLoaded: "none" | "channels" | "all" = "none"; -export function ensurePluginRegistryLoaded(): void { - if (pluginRegistryLoaded) { +export type PluginRegistryScope = "channels" | "all"; + +function resolveChannelPluginIds(params: { + config: ReturnType; + workspaceDir?: string; + env: NodeJS.ProcessEnv; +}): string[] { + return loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }) + .plugins.filter((plugin) => plugin.channels.length > 0) + .map((plugin) => plugin.id); +} + +export function ensurePluginRegistryLoaded(options?: { scope?: PluginRegistryScope }): void { + const scope = options?.scope ?? "all"; + if (pluginRegistryLoaded === "all" || pluginRegistryLoaded === scope) { return; } const active = getActivePluginRegistry(); @@ -19,7 +37,7 @@ export function ensurePluginRegistryLoaded(): void { active && (active.plugins.length > 0 || active.channels.length > 0 || active.tools.length > 0) ) { - pluginRegistryLoaded = true; + pluginRegistryLoaded = "all"; return; } const config = loadConfig(); @@ -34,6 +52,15 @@ export function ensurePluginRegistryLoaded(): void { config, workspaceDir, logger, + ...(scope === "channels" + ? { + onlyPluginIds: resolveChannelPluginIds({ + config, + workspaceDir, + env: process.env, + }), + } + : {}), }); - pluginRegistryLoaded = true; + pluginRegistryLoaded = scope; } diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index 4353b8a0d18..2a1367870c6 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -149,7 +149,7 @@ describe("registerPreActionHooks", () => { runtime: runtimeMock, commandPath: ["status"], }); - expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1); + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels" }); expect(process.title).toBe("openclaw-status"); vi.clearAllMocks(); @@ -164,7 +164,7 @@ describe("registerPreActionHooks", () => { runtime: runtimeMock, commandPath: ["message", "send"], }); - expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1); + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "all" }); }); it("skips help/version preaction and respects banner opt-out", async () => { diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 5e029c84858..ccd84e3201e 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -67,6 +67,10 @@ function loadPluginRegistryModule() { return pluginRegistryModulePromise; } +function resolvePluginRegistryScope(commandPath: string[]): "channels" | "all" { + return commandPath[0] === "status" || commandPath[0] === "health" ? "channels" : "all"; +} + function getRootCommand(command: Command): Command { let current = command; while (current.parent) { @@ -136,7 +140,7 @@ export function registerPreActionHooks(program: Command, programVersion: string) // Load plugins for commands that need channel access if (PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); - ensurePluginRegistryLoaded(); + ensurePluginRegistryLoaded({ scope: resolvePluginRegistryScope(commandPath) }); } }); } diff --git a/src/cli/route.test.ts b/src/cli/route.test.ts index c2b2270fd0a..93516906ad0 100644 --- a/src/cli/route.test.ts +++ b/src/cli/route.test.ts @@ -37,7 +37,7 @@ describe("tryRouteCli", () => { vi.resetModules(); ({ tryRouteCli } = await import("./route.js")); findRoutedCommandMock.mockReturnValue({ - loadPlugins: false, + loadPlugins: true, run: runRouteMock, }); }); @@ -59,6 +59,7 @@ describe("tryRouteCli", () => { suppressDoctorStdout: true, }), ); + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels" }); }); it("does not pass suppressDoctorStdout for routed non-json commands", async () => { @@ -68,6 +69,7 @@ describe("tryRouteCli", () => { runtime: expect.any(Object), commandPath: ["status"], }); + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels" }); }); it("routes status when root options precede the command", async () => { @@ -80,5 +82,6 @@ describe("tryRouteCli", () => { runtime: expect.any(Object), commandPath: ["status"], }); + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels" }); }); }); diff --git a/src/cli/route.ts b/src/cli/route.ts index b1d7b2851e1..763000a3d0b 100644 --- a/src/cli/route.ts +++ b/src/cli/route.ts @@ -22,7 +22,12 @@ async function prepareRoutedCommand(params: { const shouldLoadPlugins = typeof params.loadPlugins === "function" ? params.loadPlugins(params.argv) : params.loadPlugins; if (shouldLoadPlugins) { - ensurePluginRegistryLoaded(); + ensurePluginRegistryLoaded({ + scope: + params.commandPath[0] === "status" || params.commandPath[0] === "health" + ? "channels" + : "all", + }); } } diff --git a/src/entry.test.ts b/src/entry.test.ts new file mode 100644 index 00000000000..8d444d5c205 --- /dev/null +++ b/src/entry.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from "vitest"; +import { tryHandleRootHelpFastPath } from "./entry.js"; + +describe("entry root help fast path", () => { + it("renders root help without importing the full program", () => { + const outputRootHelpMock = vi.fn(); + + const handled = tryHandleRootHelpFastPath(["node", "openclaw", "--help"], { + outputRootHelp: outputRootHelpMock, + }); + + expect(handled).toBe(true); + expect(outputRootHelpMock).toHaveBeenCalledTimes(1); + }); + + it("ignores non-root help invocations", () => { + const outputRootHelpMock = vi.fn(); + + const handled = tryHandleRootHelpFastPath(["node", "openclaw", "status", "--help"], { + outputRootHelp: outputRootHelpMock, + }); + + expect(handled).toBe(false); + expect(outputRootHelpMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/entry.ts b/src/entry.ts index 14a839f38b9..9b693c756e3 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -145,24 +145,6 @@ if ( return true; } - function tryHandleRootHelpFastPath(argv: string[]): boolean { - if (!isRootHelpInvocation(argv)) { - return false; - } - import("./cli/program.js") - .then(({ buildProgram }) => { - buildProgram().outputHelp(); - }) - .catch((error) => { - console.error( - "[openclaw] Failed to display help:", - error instanceof Error ? (error.stack ?? error.message) : error, - ); - process.exitCode = 1; - }); - return true; - } - process.argv = normalizeWindowsArgv(process.argv); if (!ensureExperimentalWarningSuppressed()) { @@ -179,16 +161,58 @@ if ( process.argv = parsed.argv; } - if (!tryHandleRootVersionFastPath(process.argv) && !tryHandleRootHelpFastPath(process.argv)) { - import("./cli/run-main.js") - .then(({ runCli }) => runCli(process.argv)) - .catch((error) => { - console.error( - "[openclaw] Failed to start CLI:", - error instanceof Error ? (error.stack ?? error.message) : error, - ); - process.exitCode = 1; - }); + if (!tryHandleRootVersionFastPath(process.argv)) { + runMainOrRootHelp(process.argv); } } } + +export function tryHandleRootHelpFastPath( + argv: string[], + deps: { + outputRootHelp?: () => void; + onError?: (error: unknown) => void; + } = {}, +): boolean { + if (!isRootHelpInvocation(argv)) { + return false; + } + const handleError = + deps.onError ?? + ((error: unknown) => { + console.error( + "[openclaw] Failed to display help:", + error instanceof Error ? (error.stack ?? error.message) : error, + ); + process.exitCode = 1; + }); + if (deps.outputRootHelp) { + try { + deps.outputRootHelp(); + } catch (error) { + handleError(error); + } + return true; + } + import("./cli/program/root-help.js") + .then(({ outputRootHelp }) => { + outputRootHelp(); + }) + .catch(handleError); + return true; +} + +function runMainOrRootHelp(argv: string[]): void { + if (tryHandleRootHelpFastPath(argv)) { + return; + } + import("./cli/run-main.js") + .then(({ runCli }) => runCli(argv)) + .catch((error) => { + console.error( + "[openclaw] Failed to start CLI:", + error instanceof Error ? (error.stack ?? error.message) : error, + ); + process.exitCode = 1; + }); +} From a782358c9b643ae55ff8d7890ebaf5b841af50ad Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 17:37:51 -0700 Subject: [PATCH 069/943] Matrix: lazy-load runtime-heavy channel paths --- extensions/matrix/index.test.ts | 33 +++++++++++++++ extensions/matrix/index.ts | 5 --- extensions/matrix/src/channel.runtime.ts | 6 +++ extensions/matrix/src/channel.ts | 40 ++++++++++++++----- .../matrix/src/matrix/client/create-client.ts | 2 + 5 files changed, 71 insertions(+), 15 deletions(-) create mode 100644 extensions/matrix/index.test.ts create mode 100644 extensions/matrix/src/channel.runtime.ts diff --git a/extensions/matrix/index.test.ts b/extensions/matrix/index.test.ts new file mode 100644 index 00000000000..647f841487b --- /dev/null +++ b/extensions/matrix/index.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const setMatrixRuntimeMock = vi.hoisted(() => vi.fn()); +const registerChannelMock = vi.hoisted(() => vi.fn()); + +vi.mock("./src/runtime.js", () => ({ + setMatrixRuntime: setMatrixRuntimeMock, +})); + +const { default: matrixPlugin } = await import("./index.js"); + +describe("matrix plugin registration", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("registers the channel without bootstrapping crypto runtime", () => { + const runtime = {} as never; + matrixPlugin.register({ + runtime, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + registerChannel: registerChannelMock, + } as never); + + expect(setMatrixRuntimeMock).toHaveBeenCalledWith(runtime); + expect(registerChannelMock).toHaveBeenCalledWith({ plugin: expect.any(Object) }); + }); +}); diff --git a/extensions/matrix/index.ts b/extensions/matrix/index.ts index 9e4863a1ed8..46a4ba5864f 100644 --- a/extensions/matrix/index.ts +++ b/extensions/matrix/index.ts @@ -1,7 +1,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/matrix"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/matrix"; import { matrixPlugin } from "./src/channel.js"; -import { ensureMatrixCryptoRuntime } from "./src/matrix/deps.js"; import { setMatrixRuntime } from "./src/runtime.js"; const plugin = { @@ -11,10 +10,6 @@ const plugin = { configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { setMatrixRuntime(api.runtime); - void ensureMatrixCryptoRuntime({ log: api.logger.info }).catch((err) => { - const message = err instanceof Error ? err.message : String(err); - api.logger.warn?.(`matrix: crypto runtime bootstrap failed: ${message}`); - }); api.registerChannel({ plugin: matrixPlugin }); }, }; diff --git a/extensions/matrix/src/channel.runtime.ts b/extensions/matrix/src/channel.runtime.ts new file mode 100644 index 00000000000..bcce71da2d1 --- /dev/null +++ b/extensions/matrix/src/channel.runtime.ts @@ -0,0 +1,6 @@ +export { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js"; +export { resolveMatrixAuth } from "./matrix/client.js"; +export { probeMatrix } from "./matrix/probe.js"; +export { sendMessageMatrix } from "./matrix/send.js"; +export { resolveMatrixTargets } from "./resolve-targets.js"; +export { matrixOutbound } from "./outbound.js"; diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index bad3322f8d0..c9f95d3d671 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -18,7 +18,6 @@ import { import { buildTrafficStatusSummary } from "../../shared/channel-status-summary.js"; import { matrixMessageActions } from "./actions.js"; import { MatrixConfigSchema } from "./config-schema.js"; -import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js"; import { resolveMatrixGroupRequireMention, resolveMatrixGroupToolPolicy, @@ -30,19 +29,19 @@ import { resolveMatrixAccount, type ResolvedMatrixAccount, } from "./matrix/accounts.js"; -import { resolveMatrixAuth } from "./matrix/client.js"; import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js"; -import { probeMatrix } from "./matrix/probe.js"; -import { sendMessageMatrix } from "./matrix/send.js"; import { matrixOnboardingAdapter } from "./onboarding.js"; -import { matrixOutbound } from "./outbound.js"; -import { resolveMatrixTargets } from "./resolve-targets.js"; +import { getMatrixRuntime } from "./runtime.js"; import { normalizeSecretInputString } from "./secret-input.js"; import type { CoreConfig } from "./types.js"; // Mutex for serializing account startup (workaround for concurrent dynamic import race condition) let matrixStartupLock: Promise = Promise.resolve(); +async function loadMatrixChannelRuntime() { + return await import("./channel.runtime.js"); +} + const meta = { id: "matrix", label: "Matrix", @@ -138,6 +137,7 @@ export const matrixPlugin: ChannelPlugin = { idLabel: "matrixUserId", normalizeAllowEntry: (entry) => entry.replace(/^matrix:/i, ""), notifyApproval: async ({ id }) => { + const { sendMessageMatrix } = await loadMatrixChannelRuntime(); await sendMessageMatrix(`user:${id}`, PAIRING_APPROVED_MESSAGE); }, }, @@ -297,13 +297,23 @@ export const matrixPlugin: ChannelPlugin = { return ids; }, listPeersLive: async ({ cfg, accountId, query, limit }) => - listMatrixDirectoryPeersLive({ cfg, accountId, query, limit }), + (await loadMatrixChannelRuntime()).listMatrixDirectoryPeersLive({ + cfg, + accountId, + query, + limit, + }), listGroupsLive: async ({ cfg, accountId, query, limit }) => - listMatrixDirectoryGroupsLive({ cfg, accountId, query, limit }), + (await loadMatrixChannelRuntime()).listMatrixDirectoryGroupsLive({ + cfg, + accountId, + query, + limit, + }), }, resolver: { resolveTargets: async ({ cfg, inputs, kind, runtime }) => - resolveMatrixTargets({ cfg, inputs, kind, runtime }), + (await loadMatrixChannelRuntime()).resolveMatrixTargets({ cfg, inputs, kind, runtime }), }, actions: matrixMessageActions, setup: { @@ -367,7 +377,16 @@ export const matrixPlugin: ChannelPlugin = { }); }, }, - outbound: matrixOutbound, + outbound: { + deliveryMode: "direct", + chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + sendText: async (params) => (await loadMatrixChannelRuntime()).matrixOutbound.sendText(params), + sendMedia: async (params) => + (await loadMatrixChannelRuntime()).matrixOutbound.sendMedia(params), + sendPoll: async (params) => (await loadMatrixChannelRuntime()).matrixOutbound.sendPoll(params), + }, status: { defaultRuntime: { accountId: DEFAULT_ACCOUNT_ID, @@ -381,6 +400,7 @@ export const matrixPlugin: ChannelPlugin = { buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }), probeAccount: async ({ account, timeoutMs, cfg }) => { try { + const { probeMatrix, resolveMatrixAuth } = await loadMatrixChannelRuntime(); const auth = await resolveMatrixAuth({ cfg: cfg as CoreConfig, accountId: account.accountId, diff --git a/extensions/matrix/src/matrix/client/create-client.ts b/extensions/matrix/src/matrix/client/create-client.ts index 55cf210449c..2e1d4040612 100644 --- a/extensions/matrix/src/matrix/client/create-client.ts +++ b/extensions/matrix/src/matrix/client/create-client.ts @@ -4,6 +4,7 @@ import type { ICryptoStorageProvider, MatrixClient, } from "@vector-im/matrix-bot-sdk"; +import { ensureMatrixCryptoRuntime } from "../deps.js"; import { loadMatrixSdk } from "../sdk-runtime.js"; import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; import { @@ -44,6 +45,7 @@ export async function createMatrixClient(params: { localTimeoutMs?: number; accountId?: string | null; }): Promise { + await ensureMatrixCryptoRuntime(); const { MatrixClient, SimpleFsStorageProvider, RustSdkCryptoStorageProvider, LogService } = loadMatrixSdk(); ensureMatrixSdkLoggingConfigured(); From c0e0115b3118b17567b70d3b28f8e426d7437e98 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 17:42:48 -0700 Subject: [PATCH 070/943] CI: add CLI startup memory regression check --- .github/workflows/ci.yml | 23 ++++++ package.json | 1 + scripts/check-cli-startup-memory.mjs | 112 +++++++++++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 scripts/check-cli-startup-memory.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a11e7331e5a..9922ceb12f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -232,6 +232,29 @@ jobs: - name: Enforce safe external URL opening policy run: pnpm lint:ui:no-raw-window-open + startup-memory: + name: "startup-memory" + needs: [docs-scope, changed-scope] + if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: false + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + use-sticky-disk: "false" + + - name: Build dist + run: pnpm build + + - name: Check CLI startup memory + run: pnpm test:startup:memory + # Validate docs (format, lint, broken links) only when docs files changed. check-docs: needs: [docs-scope] diff --git a/package.json b/package.json index d8f1e530d9b..2fc0ec447d0 100644 --- a/package.json +++ b/package.json @@ -336,6 +336,7 @@ "test:perf:budget": "node scripts/test-perf-budget.mjs", "test:perf:hotspots": "node scripts/test-hotspots.mjs", "test:sectriage": "pnpm exec vitest run --config vitest.gateway.config.ts && vitest run --config vitest.unit.config.ts --exclude src/daemon/launchd.integration.test.ts --exclude src/process/exec.test.ts", + "test:startup:memory": "node scripts/check-cli-startup-memory.mjs", "test:ui": "pnpm lint:ui:no-raw-window-open && pnpm --dir ui test", "test:voicecall:closedloop": "vitest run extensions/voice-call/src/manager.test.ts extensions/voice-call/src/media-stream.test.ts src/plugins/voice-call.plugin.test.ts --maxWorkers=1", "test:watch": "vitest", diff --git a/scripts/check-cli-startup-memory.mjs b/scripts/check-cli-startup-memory.mjs new file mode 100644 index 00000000000..dbf666e1bfb --- /dev/null +++ b/scripts/check-cli-startup-memory.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const isLinux = process.platform === "linux"; +const isMac = process.platform === "darwin"; + +if (!isLinux && !isMac) { + console.log(`[startup-memory] Skipping on unsupported platform: ${process.platform}`); + process.exit(0); +} + +const repoRoot = process.cwd(); +const tmpHome = mkdtempSync(path.join(os.tmpdir(), "openclaw-startup-memory-")); + +const DEFAULT_LIMITS_MB = { + help: 500, + statusJson: 900, + gatewayStatus: 900, +}; + +const cases = [ + { + id: "help", + label: "--help", + args: ["node", "openclaw.mjs", "--help"], + limitMb: Number(process.env.OPENCLAW_STARTUP_MEMORY_HELP_MB ?? DEFAULT_LIMITS_MB.help), + }, + { + id: "statusJson", + label: "status --json", + args: ["node", "openclaw.mjs", "status", "--json"], + limitMb: Number( + process.env.OPENCLAW_STARTUP_MEMORY_STATUS_JSON_MB ?? DEFAULT_LIMITS_MB.statusJson, + ), + }, + { + id: "gatewayStatus", + label: "gateway status", + args: ["node", "openclaw.mjs", "gateway", "status"], + limitMb: Number( + process.env.OPENCLAW_STARTUP_MEMORY_GATEWAY_STATUS_MB ?? DEFAULT_LIMITS_MB.gatewayStatus, + ), + }, +]; + +function parseMaxRssMb(stderr) { + if (isLinux) { + const match = stderr.match(/^\s*Maximum resident set size \(kbytes\):\s*(\d+)\s*$/im); + if (!match) { + return null; + } + return Number(match[1]) / 1024; + } + const match = stderr.match(/^\s*(\d+)\s+maximum resident set size\s*$/im); + if (!match) { + return null; + } + return Number(match[1]) / (1024 * 1024); +} + +function runCase(testCase) { + const env = { + ...process.env, + HOME: tmpHome, + XDG_CONFIG_HOME: path.join(tmpHome, ".config"), + XDG_DATA_HOME: path.join(tmpHome, ".local", "share"), + XDG_CACHE_HOME: path.join(tmpHome, ".cache"), + }; + const timeArgs = isLinux ? ["-v", ...testCase.args] : ["-l", ...testCase.args]; + const result = spawnSync("/usr/bin/time", timeArgs, { + cwd: repoRoot, + env, + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + }); + const stderr = result.stderr ?? ""; + const maxRssMb = parseMaxRssMb(stderr); + const matrixBootstrapWarning = /matrix: crypto runtime bootstrap failed/i.test(stderr); + + if (result.status !== 0) { + throw new Error( + `${testCase.label} exited with ${String(result.status)}\n${stderr.trim() || result.stdout || ""}`, + ); + } + if (maxRssMb == null) { + throw new Error(`${testCase.label} did not report max RSS\n${stderr.trim()}`); + } + if (matrixBootstrapWarning) { + throw new Error(`${testCase.label} triggered Matrix crypto bootstrap during startup`); + } + if (maxRssMb > testCase.limitMb) { + throw new Error( + `${testCase.label} used ${maxRssMb.toFixed(1)} MB RSS (limit ${testCase.limitMb} MB)`, + ); + } + + console.log( + `[startup-memory] ${testCase.label}: ${maxRssMb.toFixed(1)} MB RSS (limit ${testCase.limitMb} MB)`, + ); +} + +try { + for (const testCase of cases) { + runCase(testCase); + } +} finally { + rmSync(tmpHome, { recursive: true, force: true }); +} From da4f82503f3c80613ee7dd1da8e530b009da5468 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 17:50:35 -0700 Subject: [PATCH 071/943] MSTeams: lazy-load runtime-heavy channel paths --- extensions/msteams/src/channel.runtime.ts | 4 +++ extensions/msteams/src/channel.ts | 32 +++++++++++++++++------ 2 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 extensions/msteams/src/channel.runtime.ts diff --git a/extensions/msteams/src/channel.runtime.ts b/extensions/msteams/src/channel.runtime.ts new file mode 100644 index 00000000000..45a0147f46b --- /dev/null +++ b/extensions/msteams/src/channel.runtime.ts @@ -0,0 +1,4 @@ +export { listMSTeamsDirectoryGroupsLive, listMSTeamsDirectoryPeersLive } from "./directory-live.js"; +export { msteamsOutbound } from "./outbound.js"; +export { probeMSTeams } from "./probe.js"; +export { sendAdaptiveCardMSTeams, sendMessageMSTeams } from "./send.js"; diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index cc1eca50fcb..a5c8f0bbe58 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -16,11 +16,8 @@ import { MSTeamsConfigSchema, PAIRING_APPROVED_MESSAGE, } from "openclaw/plugin-sdk/msteams"; -import { listMSTeamsDirectoryGroupsLive, listMSTeamsDirectoryPeersLive } from "./directory-live.js"; import { msteamsOnboardingAdapter } from "./onboarding.js"; -import { msteamsOutbound } from "./outbound.js"; import { resolveMSTeamsGroupToolPolicy } from "./policy.js"; -import { probeMSTeams } from "./probe.js"; import { normalizeMSTeamsMessagingTarget, normalizeMSTeamsUserInput, @@ -29,7 +26,7 @@ import { resolveMSTeamsChannelAllowlist, resolveMSTeamsUserAllowlist, } from "./resolve-allowlist.js"; -import { sendAdaptiveCardMSTeams, sendMessageMSTeams } from "./send.js"; +import { getMSTeamsRuntime } from "./runtime.js"; import { resolveMSTeamsCredentials } from "./token.js"; type ResolvedMSTeamsAccount = { @@ -49,6 +46,10 @@ const meta = { order: 60, } as const; +async function loadMSTeamsChannelRuntime() { + return await import("./channel.runtime.js"); +} + export const msteamsPlugin: ChannelPlugin = { id: "msteams", meta: { @@ -60,6 +61,7 @@ export const msteamsPlugin: ChannelPlugin = { idLabel: "msteamsUserId", normalizeAllowEntry: (entry) => entry.replace(/^(msteams|user):/i, ""), notifyApproval: async ({ cfg, id }) => { + const { sendMessageMSTeams } = await loadMSTeamsChannelRuntime(); await sendMessageMSTeams({ cfg, to: id, @@ -233,9 +235,9 @@ export const msteamsPlugin: ChannelPlugin = { .map((id) => ({ kind: "group", id }) as const); }, listPeersLive: async ({ cfg, query, limit }) => - listMSTeamsDirectoryPeersLive({ cfg, query, limit }), + (await loadMSTeamsChannelRuntime()).listMSTeamsDirectoryPeersLive({ cfg, query, limit }), listGroupsLive: async ({ cfg, query, limit }) => - listMSTeamsDirectoryGroupsLive({ cfg, query, limit }), + (await loadMSTeamsChannelRuntime()).listMSTeamsDirectoryGroupsLive({ cfg, query, limit }), }, resolver: { resolveTargets: async ({ cfg, inputs, kind, runtime }) => { @@ -398,6 +400,7 @@ export const msteamsPlugin: ChannelPlugin = { details: { error: "Card send requires a target (to)." }, }; } + const { sendAdaptiveCardMSTeams } = await loadMSTeamsChannelRuntime(); const result = await sendAdaptiveCardMSTeams({ cfg: ctx.cfg, to, @@ -422,14 +425,27 @@ export const msteamsPlugin: ChannelPlugin = { return null as never; }, }, - outbound: msteamsOutbound, + outbound: { + deliveryMode: "direct", + chunker: (text, limit) => getMSTeamsRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + pollMaxOptions: 12, + sendText: async (params) => + (await loadMSTeamsChannelRuntime()).msteamsOutbound.sendText!(params), + sendMedia: async (params) => + (await loadMSTeamsChannelRuntime()).msteamsOutbound.sendMedia!(params), + sendPoll: async (params) => + (await loadMSTeamsChannelRuntime()).msteamsOutbound.sendPoll!(params), + }, status: { defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }), buildChannelSummary: ({ snapshot }) => buildProbeChannelStatusSummary(snapshot, { port: snapshot.port ?? null, }), - probeAccount: async ({ cfg }) => await probeMSTeams(cfg.channels?.msteams), + probeAccount: async ({ cfg }) => + await (await loadMSTeamsChannelRuntime()).probeMSTeams(cfg.channels?.msteams), buildAccountSnapshot: ({ account, runtime, probe }) => ({ accountId: account.accountId, enabled: account.enabled, From 33495f32e922068544c6e7d4a5d5019c547f64a1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 17:56:23 -0700 Subject: [PATCH 072/943] refactor: expand setup wizard flow --- src/channels/plugins/setup-wizard.ts | 64 +++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/src/channels/plugins/setup-wizard.ts b/src/channels/plugins/setup-wizard.ts index b9dc4085dc4..f71d1802aa3 100644 --- a/src/channels/plugins/setup-wizard.ts +++ b/src/channels/plugins/setup-wizard.ts @@ -110,6 +110,7 @@ export type ChannelSetupWizardTextInput = { message: string; placeholder?: string; required?: boolean; + applyEmptyValue?: boolean; helpTitle?: string; helpLines?: string[]; confirmCurrentValue?: boolean; @@ -223,15 +224,40 @@ export type ChannelSetupWizardPrepare = (params: { credentialValues?: ChannelSetupWizardCredentialValues; } | void>; +export type ChannelSetupWizardFinalize = (params: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: ChannelSetupWizardCredentialValues; + runtime: ChannelOnboardingConfigureContext["runtime"]; + prompter: WizardPrompter; + options?: ChannelOnboardingConfigureContext["options"]; + forceAllowFrom: boolean; +}) => + | { + cfg?: OpenClawConfig; + credentialValues?: ChannelSetupWizardCredentialValues; + } + | void + | Promise<{ + cfg?: OpenClawConfig; + credentialValues?: ChannelSetupWizardCredentialValues; + } | void>; + export type ChannelSetupWizard = { channel: string; status: ChannelSetupWizardStatus; introNote?: ChannelSetupWizardNote; envShortcut?: ChannelSetupWizardEnvShortcut; + resolveShouldPromptAccountIds?: (params: { + cfg: OpenClawConfig; + options?: ChannelOnboardingConfigureContext["options"]; + shouldPromptAccountIds: boolean; + }) => boolean; prepare?: ChannelSetupWizardPrepare; stepOrder?: "credentials-first" | "text-first"; credentials: ChannelSetupWizardCredential[]; textInputs?: ChannelSetupWizardTextInput[]; + finalize?: ChannelSetupWizardFinalize; completionNote?: ChannelSetupWizardNote; dmPolicy?: ChannelOnboardingDmPolicy; allowFrom?: ChannelSetupWizardAllowFrom; @@ -384,12 +410,18 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { plugin.config.defaultAccountId?.(cfg) ?? plugin.config.listAccountIds(cfg)[0] ?? DEFAULT_ACCOUNT_ID; + const resolvedShouldPromptAccountIds = + wizard.resolveShouldPromptAccountIds?.({ + cfg, + options, + shouldPromptAccountIds, + }) ?? shouldPromptAccountIds; const accountId = await resolveAccountIdForConfigure({ cfg, prompter, label: plugin.meta.label, accountOverride: accountOverrides[plugin.id], - shouldPromptAccountIds, + shouldPromptAccountIds: resolvedShouldPromptAccountIds, listAccountIds: plugin.config.listAccountIds, defaultAccountId, }); @@ -650,6 +682,15 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { ); const trimmedValue = rawValue.trim(); if (!trimmedValue && textInput.required === false) { + if (textInput.applyEmptyValue) { + next = await applyWizardTextInputValue({ + plugin, + input: textInput, + cfg: next, + accountId, + value: "", + }); + } delete credentialValues[textInput.inputKey]; continue; } @@ -761,6 +802,27 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { }); } + if (wizard.finalize) { + const finalized = await wizard.finalize({ + cfg: next, + accountId, + credentialValues, + runtime, + prompter, + options, + forceAllowFrom, + }); + if (finalized?.cfg) { + next = finalized.cfg; + } + if (finalized?.credentialValues) { + credentialValues = { + ...credentialValues, + ...finalized.credentialValues, + }; + } + } + const shouldShowCompletionNote = wizard.completionNote && (wizard.completionNote.shouldShow From 0da588d2d2559df33f8df3a028ab44053a1c1a8e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 17:56:31 -0700 Subject: [PATCH 073/943] refactor: move whatsapp to setup wizard --- extensions/whatsapp/src/channel.ts | 51 +--- extensions/whatsapp/src/onboarding.test.ts | 18 +- .../src/{onboarding.ts => setup-surface.ts} | 263 ++++++++++-------- src/commands/onboarding/registry.ts | 6 +- 4 files changed, 167 insertions(+), 171 deletions(-) rename extensions/whatsapp/src/{onboarding.ts => setup-surface.ts} (60%) diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 1745f8caa74..e240824c743 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,5 +1,4 @@ import { - applyAccountNameToChannelSection, buildChannelConfigSchema, buildAccountScopedDmSecurityPolicy, collectAllowlistProviderGroupPolicyWarnings, @@ -10,8 +9,6 @@ import { getChatChannelMeta, listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, - migrateBaseNameToDefaultAccount, - normalizeAccountId, normalizeE164, formatWhatsAppConfigAllowFromEntries, readStringParam, @@ -35,8 +32,8 @@ import { type ResolvedWhatsAppAccount, } from "./accounts.js"; import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./normalize.js"; -import { whatsappOnboardingAdapter } from "./onboarding.js"; import { getWhatsAppRuntime } from "./runtime.js"; +import { whatsappSetupAdapter, whatsappSetupWizard } from "./setup-surface.js"; import { collectWhatsAppStatusIssues } from "./status-issues.js"; const meta = getChatChannelMeta("whatsapp"); @@ -50,7 +47,7 @@ export const whatsappPlugin: ChannelPlugin = { forceAccountBinding: true, preferSessionLookupForAnnounceTarget: true, }, - onboarding: whatsappOnboardingAdapter, + setupWizard: whatsappSetupWizard, agentTools: () => [getWhatsAppRuntime().channel.whatsapp.createLoginTool()], pairing: { idLabel: "whatsappSenderId", @@ -163,49 +160,7 @@ export const whatsappPlugin: ChannelPlugin = { }); }, }, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: "whatsapp", - accountId, - name, - alwaysUseAccounts: true, - }), - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: "whatsapp", - accountId, - name: input.name, - alwaysUseAccounts: true, - }); - const next = migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "whatsapp", - alwaysUseAccounts: true, - }); - const entry = { - ...next.channels?.whatsapp?.accounts?.[accountId], - ...(input.authDir ? { authDir: input.authDir } : {}), - enabled: true, - }; - return { - ...next, - channels: { - ...next.channels, - whatsapp: { - ...next.channels?.whatsapp, - accounts: { - ...next.channels?.whatsapp?.accounts, - [accountId]: entry, - }, - }, - }, - }; - }, - }, + setup: whatsappSetupAdapter, groups: { resolveRequireMention: resolveWhatsAppGroupRequireMention, resolveToolPolicy: resolveWhatsAppGroupToolPolicy, diff --git a/extensions/whatsapp/src/onboarding.test.ts b/extensions/whatsapp/src/onboarding.test.ts index b046928cf15..bf816e3f03d 100644 --- a/extensions/whatsapp/src/onboarding.test.ts +++ b/extensions/whatsapp/src/onboarding.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import type { RuntimeEnv } from "../../../src/runtime.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; -import { whatsappOnboardingAdapter } from "./onboarding.js"; +import { whatsappPlugin } from "./channel.js"; const loginWebMock = vi.hoisted(() => vi.fn(async () => {})); const pathExistsMock = vi.hoisted(() => vi.fn(async () => false)); @@ -82,16 +83,21 @@ function createRuntime(): RuntimeEnv { } as unknown as RuntimeEnv; } +const whatsappConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: whatsappPlugin, + wizard: whatsappPlugin.setupWizard!, +}); + async function runConfigureWithHarness(params: { harness: ReturnType; - cfg?: Parameters[0]["cfg"]; + cfg?: Parameters[0]["cfg"]; runtime?: RuntimeEnv; - options?: Parameters[0]["options"]; - accountOverrides?: Parameters[0]["accountOverrides"]; + options?: Parameters[0]["options"]; + accountOverrides?: Parameters[0]["accountOverrides"]; shouldPromptAccountIds?: boolean; forceAllowFrom?: boolean; }) { - return await whatsappOnboardingAdapter.configure({ + return await whatsappConfigureAdapter.configure({ cfg: params.cfg ?? {}, runtime: params.runtime ?? createRuntime(), prompter: params.harness.prompter, @@ -122,7 +128,7 @@ async function runSeparatePhoneFlow(params: { selectValues: string[]; textValues return { harness, result }; } -describe("whatsappOnboardingAdapter.configure", () => { +describe("whatsapp setup wizard", () => { beforeEach(() => { vi.clearAllMocks(); pathExistsMock.mockResolvedValue(false); diff --git a/extensions/whatsapp/src/onboarding.ts b/extensions/whatsapp/src/setup-surface.ts similarity index 60% rename from extensions/whatsapp/src/onboarding.ts rename to extensions/whatsapp/src/setup-surface.ts index e68fc42a5c3..180f84a3fbf 100644 --- a/extensions/whatsapp/src/onboarding.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -1,26 +1,24 @@ import path from "node:path"; import { loginWeb } from "../../../src/channel-web.js"; -import type { ChannelOnboardingAdapter } from "../../../src/channels/plugins/onboarding-types.js"; import { normalizeAllowFromEntries, - resolveAccountIdForConfigure, - resolveOnboardingAccountId, splitOnboardingEntries, } from "../../../src/channels/plugins/onboarding/helpers.js"; +import { setOnboardingChannelEnabled } from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import { formatCliCommand } from "../../../src/cli/command-format.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { mergeWhatsAppConfig } from "../../../src/config/merge-config.js"; import type { DmPolicy } from "../../../src/config/types.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import type { RuntimeEnv } from "../../../src/runtime.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import { normalizeE164, pathExists } from "../../../src/utils.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; -import { - listWhatsAppAccountIds, - resolveDefaultWhatsAppAccountId, - resolveWhatsAppAuthDir, -} from "./accounts.js"; +import { listWhatsAppAccountIds, resolveWhatsAppAuthDir } from "./accounts.js"; const channel = "whatsapp" as const; @@ -43,8 +41,8 @@ async function detectWhatsAppLinked(cfg: OpenClawConfig, accountId: string): Pro } async function promptWhatsAppOwnerAllowFrom(params: { - prompter: WizardPrompter; existingAllowFrom: string[]; + prompter: Parameters>[0]["prompter"]; }): Promise<{ normalized: string; allowFrom: string[] }> { const { prompter, existingAllowFrom } = params; @@ -82,10 +80,10 @@ async function promptWhatsAppOwnerAllowFrom(params: { async function applyWhatsAppOwnerAllowlist(params: { cfg: OpenClawConfig; - prompter: WizardPrompter; existingAllowFrom: string[]; - title: string; messageLines: string[]; + prompter: Parameters>[0]["prompter"]; + title: string; }): Promise { const { normalized, allowFrom } = await promptWhatsAppOwnerAllowFrom({ prompter: params.prompter, @@ -121,27 +119,26 @@ function parseWhatsAppAllowFromEntries(raw: string): { entries: string[]; invali return { entries: normalizeAllowFromEntries(entries, normalizeE164) }; } -async function promptWhatsAppAllowFrom( - cfg: OpenClawConfig, - _runtime: RuntimeEnv, - prompter: WizardPrompter, - options?: { forceAllowlist?: boolean }, -): Promise { - const existingPolicy = cfg.channels?.whatsapp?.dmPolicy ?? "pairing"; - const existingAllowFrom = cfg.channels?.whatsapp?.allowFrom ?? []; +async function promptWhatsAppDmAccess(params: { + cfg: OpenClawConfig; + forceAllowFrom: boolean; + prompter: Parameters>[0]["prompter"]; +}): Promise { + const existingPolicy = params.cfg.channels?.whatsapp?.dmPolicy ?? "pairing"; + const existingAllowFrom = params.cfg.channels?.whatsapp?.allowFrom ?? []; const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; - if (options?.forceAllowlist) { + if (params.forceAllowFrom) { return await applyWhatsAppOwnerAllowlist({ - cfg, - prompter, + cfg: params.cfg, + prompter: params.prompter, existingAllowFrom, title: "WhatsApp allowlist", messageLines: ["Allowlist mode enabled."], }); } - await prompter.note( + await params.prompter.note( [ "WhatsApp direct chats are gated by `channels.whatsapp.dmPolicy` + `channels.whatsapp.allowFrom`.", "- pairing (default): unknown senders get a pairing code; owner approves", @@ -155,7 +152,7 @@ async function promptWhatsAppAllowFrom( "WhatsApp DM access", ); - const phoneMode = await prompter.select({ + const phoneMode = await params.prompter.select({ message: "WhatsApp phone setup", options: [ { value: "personal", label: "This is my personal phone number" }, @@ -165,8 +162,8 @@ async function promptWhatsAppAllowFrom( if (phoneMode === "personal") { return await applyWhatsAppOwnerAllowlist({ - cfg, - prompter, + cfg: params.cfg, + prompter: params.prompter, existingAllowFrom, title: "WhatsApp personal phone", messageLines: [ @@ -176,7 +173,7 @@ async function promptWhatsAppAllowFrom( }); } - const policy = (await prompter.select({ + const policy = (await params.prompter.select({ message: "WhatsApp DM policy", options: [ { value: "pairing", label: "Pairing (recommended)" }, @@ -186,7 +183,7 @@ async function promptWhatsAppAllowFrom( ], })) as DmPolicy; - let next = setWhatsAppSelfChatMode(cfg, false); + let next = setWhatsAppSelfChatMode(params.cfg, false); next = setWhatsAppDmPolicy(next, policy); if (policy === "open") { const allowFrom = normalizeAllowFromEntries(["*", ...existingAllowFrom], normalizeE164); @@ -212,7 +209,7 @@ async function promptWhatsAppAllowFrom( { value: "list", label: "Set allowFrom to specific numbers" }, ] as const); - const mode = await prompter.select({ + const mode = await params.prompter.select({ message: "WhatsApp allowFrom (optional pre-allowlist)", options: allowOptions.map((opt) => ({ value: opt.value, @@ -221,92 +218,123 @@ async function promptWhatsAppAllowFrom( }); if (mode === "keep") { - // Keep allowFrom as-is. - } else if (mode === "unset") { - next = setWhatsAppAllowFrom(next, undefined); - } else { - const allowRaw = await prompter.text({ - message: "Allowed sender numbers (comma-separated, E.164)", - placeholder: "+15555550123, +447700900123", - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) { - return "Required"; - } - const parsed = parseWhatsAppAllowFromEntries(raw); - if (parsed.entries.length === 0 && !parsed.invalidEntry) { - return "Required"; - } - if (parsed.invalidEntry) { - return `Invalid number: ${parsed.invalidEntry}`; - } - return undefined; - }, - }); - - const parsed = parseWhatsAppAllowFromEntries(String(allowRaw)); - next = setWhatsAppAllowFrom(next, parsed.entries); + return next; + } + if (mode === "unset") { + return setWhatsAppAllowFrom(next, undefined); } - return next; + const allowRaw = await params.prompter.text({ + message: "Allowed sender numbers (comma-separated, E.164)", + placeholder: "+15555550123, +447700900123", + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + const parsed = parseWhatsAppAllowFromEntries(raw); + if (parsed.entries.length === 0 && !parsed.invalidEntry) { + return "Required"; + } + if (parsed.invalidEntry) { + return `Invalid number: ${parsed.invalidEntry}`; + } + return undefined; + }, + }); + + const parsed = parseWhatsAppAllowFromEntries(String(allowRaw)); + return setWhatsAppAllowFrom(next, parsed.entries); } -export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg, accountOverrides }) => { - const defaultAccountId = resolveDefaultWhatsAppAccountId(cfg); - const accountId = resolveOnboardingAccountId({ - accountId: accountOverrides.whatsapp, - defaultAccountId, - }); - const linked = await detectWhatsAppLinked(cfg, accountId); - const accountLabel = accountId === DEFAULT_ACCOUNT_ID ? "default" : accountId; - return { - channel, - configured: linked, - statusLines: [`WhatsApp (${accountLabel}): ${linked ? "linked" : "not linked"}`], - selectionHint: linked ? "linked" : "not linked", - quickstartScore: linked ? 5 : 4, - }; - }, - configure: async ({ - cfg, - runtime, - prompter, - options, - accountOverrides, - shouldPromptAccountIds, - forceAllowFrom, - }) => { - const accountId = await resolveAccountIdForConfigure({ +export const whatsappSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ cfg, - prompter, - label: "WhatsApp", - accountOverride: accountOverrides.whatsapp, - shouldPromptAccountIds: Boolean(shouldPromptAccountIds || options?.promptWhatsAppAccountId), - listAccountIds: listWhatsAppAccountIds, - defaultAccountId: resolveDefaultWhatsAppAccountId(cfg), + channelKey: channel, + accountId, + name, + alwaysUseAccounts: true, + }), + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + alwaysUseAccounts: true, }); - - let next = cfg; - if (accountId !== DEFAULT_ACCOUNT_ID) { - next = { - ...next, - channels: { - ...next.channels, - whatsapp: { - ...next.channels?.whatsapp, - accounts: { - ...next.channels?.whatsapp?.accounts, - [accountId]: { - ...next.channels?.whatsapp?.accounts?.[accountId], - enabled: next.channels?.whatsapp?.accounts?.[accountId]?.enabled ?? true, - }, - }, + const next = migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + alwaysUseAccounts: true, + }); + const entry = { + ...next.channels?.whatsapp?.accounts?.[accountId], + ...(input.authDir ? { authDir: input.authDir } : {}), + enabled: true, + }; + return { + ...next, + channels: { + ...next.channels, + whatsapp: { + ...next.channels?.whatsapp, + accounts: { + ...next.channels?.whatsapp?.accounts, + [accountId]: entry, }, }, - }; - } + }, + }; + }, +}; + +export const whatsappSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "linked", + unconfiguredLabel: "not linked", + configuredHint: "linked", + unconfiguredHint: "not linked", + configuredScore: 5, + unconfiguredScore: 4, + resolveConfigured: async ({ cfg }) => { + for (const accountId of listWhatsAppAccountIds(cfg)) { + if (await detectWhatsAppLinked(cfg, accountId)) { + return true; + } + } + return false; + }, + resolveStatusLines: async ({ cfg, configured }) => { + const linkedAccountId = ( + await Promise.all( + listWhatsAppAccountIds(cfg).map(async (accountId) => ({ + accountId, + linked: await detectWhatsAppLinked(cfg, accountId), + })), + ) + ).find((entry) => entry.linked)?.accountId; + const label = linkedAccountId + ? `WhatsApp (${linkedAccountId === DEFAULT_ACCOUNT_ID ? "default" : linkedAccountId})` + : "WhatsApp"; + return [`${label}: ${configured ? "linked" : "not linked"}`]; + }, + }, + resolveShouldPromptAccountIds: ({ options, shouldPromptAccountIds }) => + Boolean(shouldPromptAccountIds || options?.promptWhatsAppAccountId), + credentials: [], + finalize: async ({ cfg, accountId, forceAllowFrom, prompter, runtime }) => { + let next = + accountId === DEFAULT_ACCOUNT_ID + ? cfg + : whatsappSetupAdapter.applyAccountConfig({ + cfg, + accountId, + input: {}, + }); const linked = await detectWhatsAppLinked(next, accountId); const { authDir } = resolveWhatsAppAuthDir({ @@ -324,6 +352,7 @@ export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = { "WhatsApp linking", ); } + const wantsLink = await prompter.confirm({ message: linked ? "WhatsApp already linked. Re-link now?" : "Link WhatsApp now (QR)?", initialValue: !linked, @@ -331,8 +360,8 @@ export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = { if (wantsLink) { try { await loginWeb(false, undefined, runtime, accountId); - } catch (err) { - runtime.error(`WhatsApp login failed: ${String(err)}`); + } catch (error) { + runtime.error(`WhatsApp login failed: ${String(error)}`); await prompter.note(`Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, "WhatsApp help"); } } else if (!linked) { @@ -342,12 +371,14 @@ export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = { ); } - next = await promptWhatsAppAllowFrom(next, runtime, prompter, { - forceAllowlist: forceAllowFrom, + next = await promptWhatsAppDmAccess({ + cfg: next, + forceAllowFrom, + prompter, }); - - return { cfg: next, accountId }; + return { cfg: next }; }, + disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), onAccountRecorded: (accountId, options) => { options?.onWhatsAppAccountId?.(accountId); }, diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts index 40bec8720f1..14074daf193 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/onboarding/registry.ts @@ -3,7 +3,7 @@ import { imessagePlugin } from "../../../extensions/imessage/src/channel.js"; import { signalPlugin } from "../../../extensions/signal/src/channel.js"; import { slackPlugin } from "../../../extensions/slack/src/channel.js"; import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; -import { whatsappOnboardingAdapter } from "../../../extensions/whatsapp/src/onboarding.js"; +import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js"; import { listChannelSetupPlugins } from "../../channels/plugins/setup-registry.js"; import { buildChannelOnboardingAdapterFromSetupWizard } from "../../channels/plugins/setup-wizard.js"; import type { ChannelChoice } from "../onboard-types.js"; @@ -29,6 +29,10 @@ const imessageOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ plugin: imessagePlugin, wizard: imessagePlugin.setupWizard!, }); +const whatsappOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: whatsappPlugin, + wizard: whatsappPlugin.setupWizard!, +}); const BUILTIN_ONBOARDING_ADAPTERS: ChannelOnboardingAdapter[] = [ telegramOnboardingAdapter, From a8bee6fb6c50fe1eaca67cf5fcfee68c3db15a42 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 17:56:37 -0700 Subject: [PATCH 074/943] refactor: move irc to setup wizard --- extensions/irc/src/channel.ts | 5 +- extensions/irc/src/onboarding.test.ts | 14 +- extensions/irc/src/onboarding.ts | 444 ------------------- extensions/irc/src/setup-surface.ts | 586 ++++++++++++++++++++++++++ 4 files changed, 599 insertions(+), 450 deletions(-) delete mode 100644 extensions/irc/src/onboarding.ts create mode 100644 extensions/irc/src/setup-surface.ts diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 62d64fb0866..b1fd0fc89d8 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -32,11 +32,11 @@ import { isChannelTarget, normalizeIrcAllowEntry, } from "./normalize.js"; -import { ircOnboardingAdapter } from "./onboarding.js"; import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js"; import { probeIrc } from "./probe.js"; import { getIrcRuntime } from "./runtime.js"; import { sendMessageIrc } from "./send.js"; +import { ircSetupAdapter, ircSetupWizard } from "./setup-surface.js"; import type { CoreConfig, IrcProbe } from "./types.js"; const meta = getChatChannelMeta("irc"); @@ -66,7 +66,8 @@ export const ircPlugin: ChannelPlugin = { ...meta, quickstartAllowFrom: true, }, - onboarding: ircOnboardingAdapter, + setup: ircSetupAdapter, + setupWizard: ircSetupWizard, pairing: { idLabel: "ircUser", normalizeAllowEntry: (entry) => normalizeIrcAllowEntry(entry), diff --git a/extensions/irc/src/onboarding.test.ts b/extensions/irc/src/onboarding.test.ts index 613503700f3..38738d1e484 100644 --- a/extensions/irc/src/onboarding.test.ts +++ b/extensions/irc/src/onboarding.test.ts @@ -1,7 +1,8 @@ import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/irc"; import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; -import { ircOnboardingAdapter } from "./onboarding.js"; +import { ircPlugin } from "./channel.js"; import type { CoreConfig } from "./types.js"; const selectFirstOption = async (params: { options: Array<{ value: T }> }): Promise => { @@ -26,7 +27,12 @@ function createPrompter(overrides: Partial): WizardPrompter { }; } -describe("irc onboarding", () => { +const ircConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: ircPlugin, + wizard: ircPlugin.setupWizard!, +}); + +describe("irc setup wizard", () => { it("configures host and nick via onboarding prompts", async () => { const prompter = createPrompter({ text: vi.fn(async ({ message }: { message: string }) => { @@ -66,7 +72,7 @@ describe("irc onboarding", () => { const runtime: RuntimeEnv = createRuntimeEnv(); - const result = await ircOnboardingAdapter.configure({ + const result = await ircConfigureAdapter.configure({ cfg: {} as CoreConfig, runtime, prompter, @@ -97,7 +103,7 @@ describe("irc onboarding", () => { confirm: vi.fn(async () => false), }); - const promptAllowFrom = ircOnboardingAdapter.dmPolicy?.promptAllowFrom; + const promptAllowFrom = ircConfigureAdapter.dmPolicy?.promptAllowFrom; expect(promptAllowFrom).toBeTypeOf("function"); const cfg: CoreConfig = { diff --git a/extensions/irc/src/onboarding.ts b/extensions/irc/src/onboarding.ts deleted file mode 100644 index 5e7c80c94d7..00000000000 --- a/extensions/irc/src/onboarding.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { - DEFAULT_ACCOUNT_ID, - formatDocsLink, - patchScopedAccountConfig, - promptChannelAccessConfig, - resolveAccountIdForConfigure, - setTopLevelChannelAllowFrom, - setTopLevelChannelDmPolicyWithAllowFrom, - type ChannelOnboardingAdapter, - type ChannelOnboardingDmPolicy, - type DmPolicy, - type WizardPrompter, -} from "openclaw/plugin-sdk/irc"; -import { listIrcAccountIds, resolveDefaultIrcAccountId, resolveIrcAccount } from "./accounts.js"; -import { - isChannelTarget, - normalizeIrcAllowEntry, - normalizeIrcMessagingTarget, -} from "./normalize.js"; -import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js"; - -const channel = "irc" as const; - -function parseListInput(raw: string): string[] { - return raw - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); -} - -function parsePort(raw: string, fallback: number): number { - const trimmed = raw.trim(); - if (!trimmed) { - return fallback; - } - const parsed = Number.parseInt(trimmed, 10); - if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) { - return fallback; - } - return parsed; -} - -function normalizeGroupEntry(raw: string): string | null { - const trimmed = raw.trim(); - if (!trimmed) { - return null; - } - if (trimmed === "*") { - return "*"; - } - const normalized = normalizeIrcMessagingTarget(trimmed) ?? trimmed; - if (isChannelTarget(normalized)) { - return normalized; - } - return `#${normalized.replace(/^#+/, "")}`; -} - -function updateIrcAccountConfig( - cfg: CoreConfig, - accountId: string, - patch: Partial, -): CoreConfig { - return patchScopedAccountConfig({ - cfg, - channelKey: channel, - accountId, - patch, - ensureChannelEnabled: false, - ensureAccountEnabled: false, - }) as CoreConfig; -} - -function setIrcDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel: "irc", - dmPolicy, - }) as CoreConfig; -} - -function setIrcAllowFrom(cfg: CoreConfig, allowFrom: string[]): CoreConfig { - return setTopLevelChannelAllowFrom({ - cfg, - channel: "irc", - allowFrom, - }) as CoreConfig; -} - -function setIrcNickServ( - cfg: CoreConfig, - accountId: string, - nickserv?: IrcNickServConfig, -): CoreConfig { - return updateIrcAccountConfig(cfg, accountId, { nickserv }); -} - -function setIrcGroupAccess( - cfg: CoreConfig, - accountId: string, - policy: "open" | "allowlist" | "disabled", - entries: string[], -): CoreConfig { - if (policy !== "allowlist") { - return updateIrcAccountConfig(cfg, accountId, { enabled: true, groupPolicy: policy }); - } - const normalizedEntries = [ - ...new Set(entries.map((entry) => normalizeGroupEntry(entry)).filter(Boolean)), - ]; - const groups = Object.fromEntries(normalizedEntries.map((entry) => [entry, {}])); - return updateIrcAccountConfig(cfg, accountId, { - enabled: true, - groupPolicy: "allowlist", - groups, - }); -} - -async function noteIrcSetupHelp(prompter: WizardPrompter): Promise { - await prompter.note( - [ - "IRC needs server host + bot nick.", - "Recommended: TLS on port 6697.", - "Optional: NickServ identify/register can be configured in onboarding.", - 'Set channels.irc.groupPolicy="allowlist" and channels.irc.groups for tighter channel control.', - 'Note: IRC channels are mention-gated by default. To allow unmentioned replies, set channels.irc.groups["#channel"].requireMention=false (or "*" for all).', - "Env vars supported: IRC_HOST, IRC_PORT, IRC_TLS, IRC_NICK, IRC_USERNAME, IRC_REALNAME, IRC_PASSWORD, IRC_CHANNELS, IRC_NICKSERV_PASSWORD, IRC_NICKSERV_REGISTER_EMAIL.", - `Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`, - ].join("\n"), - "IRC setup", - ); -} - -async function promptIrcAllowFrom(params: { - cfg: CoreConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - const existing = params.cfg.channels?.irc?.allowFrom ?? []; - - await params.prompter.note( - [ - "Allowlist IRC DMs by sender.", - "Examples:", - "- alice", - "- alice!ident@example.org", - "Multiple entries: comma-separated.", - ].join("\n"), - "IRC allowlist", - ); - - const raw = await params.prompter.text({ - message: "IRC allowFrom (nick or nick!user@host)", - placeholder: "alice, bob!ident@example.org", - initialValue: existing[0] ? String(existing[0]) : undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - - const parsed = parseListInput(String(raw)); - const normalized = [ - ...new Set( - parsed - .map((entry) => normalizeIrcAllowEntry(entry)) - .map((entry) => entry.trim()) - .filter(Boolean), - ), - ]; - return setIrcAllowFrom(params.cfg, normalized); -} - -async function promptIrcNickServConfig(params: { - cfg: CoreConfig; - prompter: WizardPrompter; - accountId: string; -}): Promise { - const resolved = resolveIrcAccount({ cfg: params.cfg, accountId: params.accountId }); - const existing = resolved.config.nickserv; - const hasExisting = Boolean(existing?.password || existing?.passwordFile); - const wants = await params.prompter.confirm({ - message: hasExisting ? "Update NickServ settings?" : "Configure NickServ identify/register?", - initialValue: hasExisting, - }); - if (!wants) { - return params.cfg; - } - - const service = String( - await params.prompter.text({ - message: "NickServ service nick", - initialValue: existing?.service || "NickServ", - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }), - ).trim(); - - const useEnvPassword = - params.accountId === DEFAULT_ACCOUNT_ID && - Boolean(process.env.IRC_NICKSERV_PASSWORD?.trim()) && - !(existing?.password || existing?.passwordFile) - ? await params.prompter.confirm({ - message: "IRC_NICKSERV_PASSWORD detected. Use env var?", - initialValue: true, - }) - : false; - - const password = useEnvPassword - ? undefined - : String( - await params.prompter.text({ - message: "NickServ password (blank to disable NickServ auth)", - validate: () => undefined, - }), - ).trim(); - - if (!password && !useEnvPassword) { - return setIrcNickServ(params.cfg, params.accountId, { - enabled: false, - service, - }); - } - - const register = await params.prompter.confirm({ - message: "Send NickServ REGISTER on connect?", - initialValue: existing?.register ?? false, - }); - const registerEmail = register - ? String( - await params.prompter.text({ - message: "NickServ register email", - initialValue: - existing?.registerEmail || - (params.accountId === DEFAULT_ACCOUNT_ID - ? process.env.IRC_NICKSERV_REGISTER_EMAIL - : undefined), - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }), - ).trim() - : undefined; - - return setIrcNickServ(params.cfg, params.accountId, { - enabled: true, - service, - ...(password ? { password } : {}), - register, - ...(registerEmail ? { registerEmail } : {}), - }); -} - -const dmPolicy: ChannelOnboardingDmPolicy = { - label: "IRC", - channel, - policyKey: "channels.irc.dmPolicy", - allowFromKey: "channels.irc.allowFrom", - getCurrent: (cfg) => (cfg as CoreConfig).channels?.irc?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => setIrcDmPolicy(cfg as CoreConfig, policy), - promptAllowFrom: promptIrcAllowFrom, -}; - -export const ircOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const coreCfg = cfg as CoreConfig; - const configured = listIrcAccountIds(coreCfg).some( - (accountId) => resolveIrcAccount({ cfg: coreCfg, accountId }).configured, - ); - return { - channel, - configured, - statusLines: [`IRC: ${configured ? "configured" : "needs host + nick"}`], - selectionHint: configured ? "configured" : "needs host + nick", - quickstartScore: configured ? 1 : 0, - }; - }, - configure: async ({ - cfg, - prompter, - accountOverrides, - shouldPromptAccountIds, - forceAllowFrom, - }) => { - let next = cfg as CoreConfig; - const defaultAccountId = resolveDefaultIrcAccountId(next); - const accountId = await resolveAccountIdForConfigure({ - cfg: next, - prompter, - label: "IRC", - accountOverride: accountOverrides.irc, - shouldPromptAccountIds, - listAccountIds: listIrcAccountIds, - defaultAccountId, - }); - - const resolved = resolveIrcAccount({ cfg: next, accountId }); - const isDefaultAccount = accountId === DEFAULT_ACCOUNT_ID; - const envHost = isDefaultAccount ? process.env.IRC_HOST?.trim() : ""; - const envNick = isDefaultAccount ? process.env.IRC_NICK?.trim() : ""; - const envReady = Boolean(envHost && envNick); - - if (!resolved.configured) { - await noteIrcSetupHelp(prompter); - } - - let useEnv = false; - if (envReady && isDefaultAccount && !resolved.config.host && !resolved.config.nick) { - useEnv = await prompter.confirm({ - message: "IRC_HOST and IRC_NICK detected. Use env vars?", - initialValue: true, - }); - } - - if (useEnv) { - next = updateIrcAccountConfig(next, accountId, { enabled: true }); - } else { - const host = String( - await prompter.text({ - message: "IRC server host", - initialValue: resolved.config.host || envHost || undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }), - ).trim(); - - const tls = await prompter.confirm({ - message: "Use TLS for IRC?", - initialValue: resolved.config.tls ?? true, - }); - const defaultPort = resolved.config.port ?? (tls ? 6697 : 6667); - const portInput = await prompter.text({ - message: "IRC server port", - initialValue: String(defaultPort), - validate: (value) => { - const parsed = Number.parseInt(String(value ?? "").trim(), 10); - return Number.isFinite(parsed) && parsed >= 1 && parsed <= 65535 - ? undefined - : "Use a port between 1 and 65535"; - }, - }); - const port = parsePort(String(portInput), defaultPort); - - const nick = String( - await prompter.text({ - message: "IRC nick", - initialValue: resolved.config.nick || envNick || undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }), - ).trim(); - - const username = String( - await prompter.text({ - message: "IRC username", - initialValue: resolved.config.username || nick || "openclaw", - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }), - ).trim(); - - const realname = String( - await prompter.text({ - message: "IRC real name", - initialValue: resolved.config.realname || "OpenClaw", - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }), - ).trim(); - - const channelsRaw = await prompter.text({ - message: "Auto-join IRC channels (optional, comma-separated)", - placeholder: "#openclaw, #ops", - initialValue: (resolved.config.channels ?? []).join(", "), - }); - const channels = [ - ...new Set( - parseListInput(String(channelsRaw)) - .map((entry) => normalizeGroupEntry(entry)) - .filter((entry): entry is string => Boolean(entry && entry !== "*")) - .filter((entry) => isChannelTarget(entry)), - ), - ]; - - next = updateIrcAccountConfig(next, accountId, { - enabled: true, - host, - port, - tls, - nick, - username, - realname, - channels: channels.length > 0 ? channels : undefined, - }); - } - - const afterConfig = resolveIrcAccount({ cfg: next, accountId }); - const accessConfig = await promptChannelAccessConfig({ - prompter, - label: "IRC channels", - currentPolicy: afterConfig.config.groupPolicy ?? "allowlist", - currentEntries: Object.keys(afterConfig.config.groups ?? {}), - placeholder: "#openclaw, #ops, *", - updatePrompt: Boolean(afterConfig.config.groups), - }); - if (accessConfig) { - next = setIrcGroupAccess(next, accountId, accessConfig.policy, accessConfig.entries); - - // Mention gating: groups/channels are mention-gated by default. Make this explicit in onboarding. - const wantsMentions = await prompter.confirm({ - message: "Require @mention to reply in IRC channels?", - initialValue: true, - }); - if (!wantsMentions) { - const resolvedAfter = resolveIrcAccount({ cfg: next, accountId }); - const groups = resolvedAfter.config.groups ?? {}; - const patched = Object.fromEntries( - Object.entries(groups).map(([key, value]) => [key, { ...value, requireMention: false }]), - ); - next = updateIrcAccountConfig(next, accountId, { groups: patched }); - } - } - - if (forceAllowFrom) { - next = await promptIrcAllowFrom({ cfg: next, prompter, accountId }); - } - next = await promptIrcNickServConfig({ - cfg: next, - prompter, - accountId, - }); - - await prompter.note( - [ - "Next: restart gateway and verify status.", - "Command: openclaw channels status --probe", - `Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`, - ].join("\n"), - "IRC next steps", - ); - - return { cfg: next, accountId }; - }, - dmPolicy, - disable: (cfg) => ({ - ...(cfg as CoreConfig), - channels: { - ...(cfg as CoreConfig).channels, - irc: { - ...(cfg as CoreConfig).channels?.irc, - enabled: false, - }, - }, - }), -}; diff --git a/extensions/irc/src/setup-surface.ts b/extensions/irc/src/setup-surface.ts new file mode 100644 index 00000000000..aaee61a9532 --- /dev/null +++ b/extensions/irc/src/setup-surface.ts @@ -0,0 +1,586 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + resolveOnboardingAccountId, + setOnboardingChannelEnabled, + setTopLevelChannelAllowFrom, + setTopLevelChannelDmPolicyWithAllowFrom, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + patchScopedAccountConfig, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; +import type { DmPolicy } from "../../../src/config/types.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { listIrcAccountIds, resolveDefaultIrcAccountId, resolveIrcAccount } from "./accounts.js"; +import { + isChannelTarget, + normalizeIrcAllowEntry, + normalizeIrcMessagingTarget, +} from "./normalize.js"; +import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js"; + +const channel = "irc" as const; +const USE_ENV_FLAG = "__ircUseEnv"; +const TLS_FLAG = "__ircTls"; + +type IrcSetupInput = ChannelSetupInput & { + host?: string; + port?: number | string; + tls?: boolean; + nick?: string; + username?: string; + realname?: string; + channels?: string[]; + password?: string; +}; + +function parseListInput(raw: string): string[] { + return raw + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function parsePort(raw: string, fallback: number): number { + const trimmed = raw.trim(); + if (!trimmed) { + return fallback; + } + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) { + return fallback; + } + return parsed; +} + +function normalizeGroupEntry(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + if (trimmed === "*") { + return "*"; + } + const normalized = normalizeIrcMessagingTarget(trimmed) ?? trimmed; + if (isChannelTarget(normalized)) { + return normalized; + } + return `#${normalized.replace(/^#+/, "")}`; +} + +function updateIrcAccountConfig( + cfg: CoreConfig, + accountId: string, + patch: Partial, +): CoreConfig { + return patchScopedAccountConfig({ + cfg, + channelKey: channel, + accountId, + patch, + ensureChannelEnabled: false, + ensureAccountEnabled: false, + }) as CoreConfig; +} + +function setIrcDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { + return setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy, + }) as CoreConfig; +} + +function setIrcAllowFrom(cfg: CoreConfig, allowFrom: string[]): CoreConfig { + return setTopLevelChannelAllowFrom({ + cfg, + channel, + allowFrom, + }) as CoreConfig; +} + +function setIrcNickServ( + cfg: CoreConfig, + accountId: string, + nickserv?: IrcNickServConfig, +): CoreConfig { + return updateIrcAccountConfig(cfg, accountId, { nickserv }); +} + +function setIrcGroupAccess( + cfg: CoreConfig, + accountId: string, + policy: "open" | "allowlist" | "disabled", + entries: string[], +): CoreConfig { + if (policy !== "allowlist") { + return updateIrcAccountConfig(cfg, accountId, { enabled: true, groupPolicy: policy }); + } + const normalizedEntries = [ + ...new Set(entries.map((entry) => normalizeGroupEntry(entry)).filter(Boolean)), + ]; + const groups = Object.fromEntries(normalizedEntries.map((entry) => [entry, {}])); + return updateIrcAccountConfig(cfg, accountId, { + enabled: true, + groupPolicy: "allowlist", + groups, + }); +} + +async function promptIrcAllowFrom(params: { + cfg: CoreConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + const existing = params.cfg.channels?.irc?.allowFrom ?? []; + + await params.prompter.note( + [ + "Allowlist IRC DMs by sender.", + "Examples:", + "- alice", + "- alice!ident@example.org", + "Multiple entries: comma-separated.", + ].join("\n"), + "IRC allowlist", + ); + + const raw = await params.prompter.text({ + message: "IRC allowFrom (nick or nick!user@host)", + placeholder: "alice, bob!ident@example.org", + initialValue: existing[0] ? String(existing[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + + const parsed = parseListInput(String(raw)); + const normalized = [ + ...new Set( + parsed + .map((entry) => normalizeIrcAllowEntry(entry)) + .map((entry) => entry.trim()) + .filter(Boolean), + ), + ]; + return setIrcAllowFrom(params.cfg, normalized); +} + +async function promptIrcNickServConfig(params: { + cfg: CoreConfig; + prompter: WizardPrompter; + accountId: string; +}): Promise { + const resolved = resolveIrcAccount({ cfg: params.cfg, accountId: params.accountId }); + const existing = resolved.config.nickserv; + const hasExisting = Boolean(existing?.password || existing?.passwordFile); + const wants = await params.prompter.confirm({ + message: hasExisting ? "Update NickServ settings?" : "Configure NickServ identify/register?", + initialValue: hasExisting, + }); + if (!wants) { + return params.cfg; + } + + const service = String( + await params.prompter.text({ + message: "NickServ service nick", + initialValue: existing?.service || "NickServ", + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }), + ).trim(); + + const useEnvPassword = + params.accountId === DEFAULT_ACCOUNT_ID && + Boolean(process.env.IRC_NICKSERV_PASSWORD?.trim()) && + !(existing?.password || existing?.passwordFile) + ? await params.prompter.confirm({ + message: "IRC_NICKSERV_PASSWORD detected. Use env var?", + initialValue: true, + }) + : false; + + const password = useEnvPassword + ? undefined + : String( + await params.prompter.text({ + message: "NickServ password (blank to disable NickServ auth)", + validate: () => undefined, + }), + ).trim(); + + if (!password && !useEnvPassword) { + return setIrcNickServ(params.cfg, params.accountId, { + enabled: false, + service, + }); + } + + const register = await params.prompter.confirm({ + message: "Send NickServ REGISTER on connect?", + initialValue: existing?.register ?? false, + }); + const registerEmail = register + ? String( + await params.prompter.text({ + message: "NickServ register email", + initialValue: + existing?.registerEmail || + (params.accountId === DEFAULT_ACCOUNT_ID + ? process.env.IRC_NICKSERV_REGISTER_EMAIL + : undefined), + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }), + ).trim() + : undefined; + + return setIrcNickServ(params.cfg, params.accountId, { + enabled: true, + service, + ...(password ? { password } : {}), + register, + ...(registerEmail ? { registerEmail } : {}), + }); +} + +const ircDmPolicy: ChannelOnboardingDmPolicy = { + label: "IRC", + channel, + policyKey: "channels.irc.dmPolicy", + allowFromKey: "channels.irc.allowFrom", + getCurrent: (cfg) => (cfg as CoreConfig).channels?.irc?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setIrcDmPolicy(cfg as CoreConfig, policy), + promptAllowFrom: async ({ cfg, prompter, accountId }) => + await promptIrcAllowFrom({ + cfg: cfg as CoreConfig, + prompter, + accountId: resolveOnboardingAccountId({ + accountId, + defaultAccountId: resolveDefaultIrcAccountId(cfg as CoreConfig), + }), + }), +}; + +export const ircSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ input }) => { + const setupInput = input as IrcSetupInput; + if (!setupInput.host?.trim()) { + return "IRC requires host."; + } + if (!setupInput.nick?.trim()) { + return "IRC requires nick."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const setupInput = input as IrcSetupInput; + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: setupInput.name, + }); + const portInput = + typeof setupInput.port === "number" ? String(setupInput.port) : String(setupInput.port ?? ""); + const patch: Partial = { + enabled: true, + host: setupInput.host?.trim(), + port: portInput ? parsePort(portInput, setupInput.tls === false ? 6667 : 6697) : undefined, + tls: setupInput.tls, + nick: setupInput.nick?.trim(), + username: setupInput.username?.trim(), + realname: setupInput.realname?.trim(), + password: setupInput.password?.trim(), + channels: setupInput.channels, + }; + return patchScopedAccountConfig({ + cfg: namedConfig, + channelKey: channel, + accountId, + patch, + }) as CoreConfig; + }, +}; + +export const ircSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs host + nick", + configuredHint: "configured", + unconfiguredHint: "needs host + nick", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => + listIrcAccountIds(cfg as CoreConfig).some( + (accountId) => resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).configured, + ), + resolveStatusLines: ({ configured }) => [ + `IRC: ${configured ? "configured" : "needs host + nick"}`, + ], + }, + introNote: { + title: "IRC setup", + lines: [ + "IRC needs server host + bot nick.", + "Recommended: TLS on port 6697.", + "Optional: NickServ identify/register can be configured after the basic account fields.", + 'Set channels.irc.groupPolicy="allowlist" and channels.irc.groups for tighter channel control.', + 'Note: IRC channels are mention-gated by default. To allow unmentioned replies, set channels.irc.groups["#channel"].requireMention=false (or "*" for all).', + "Env vars supported: IRC_HOST, IRC_PORT, IRC_TLS, IRC_NICK, IRC_USERNAME, IRC_REALNAME, IRC_PASSWORD, IRC_CHANNELS, IRC_NICKSERV_PASSWORD, IRC_NICKSERV_REGISTER_EMAIL.", + `Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`, + ], + shouldShow: ({ cfg, accountId }) => + !resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).configured, + }, + prepare: async ({ cfg, accountId, credentialValues, prompter }) => { + const resolved = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }); + const isDefaultAccount = accountId === DEFAULT_ACCOUNT_ID; + const envHost = isDefaultAccount ? process.env.IRC_HOST?.trim() : ""; + const envNick = isDefaultAccount ? process.env.IRC_NICK?.trim() : ""; + const envReady = Boolean(envHost && envNick && !resolved.config.host && !resolved.config.nick); + + if (envReady) { + const useEnv = await prompter.confirm({ + message: "IRC_HOST and IRC_NICK detected. Use env vars?", + initialValue: true, + }); + if (useEnv) { + return { + cfg: updateIrcAccountConfig(cfg as CoreConfig, accountId, { enabled: true }), + credentialValues: { + ...credentialValues, + [USE_ENV_FLAG]: "1", + }, + }; + } + } + + const tls = await prompter.confirm({ + message: "Use TLS for IRC?", + initialValue: resolved.config.tls ?? true, + }); + return { + cfg: updateIrcAccountConfig(cfg as CoreConfig, accountId, { + enabled: true, + tls, + }), + credentialValues: { + ...credentialValues, + [USE_ENV_FLAG]: "0", + [TLS_FLAG]: tls ? "1" : "0", + }, + }; + }, + credentials: [], + textInputs: [ + { + inputKey: "httpHost", + message: "IRC server host", + currentValue: ({ cfg, accountId }) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.host || undefined, + shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1", + validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"), + normalizeValue: ({ value }) => String(value).trim(), + applySet: async ({ cfg, accountId, value }) => + updateIrcAccountConfig(cfg as CoreConfig, accountId, { + enabled: true, + host: value, + }), + }, + { + inputKey: "httpPort", + message: "IRC server port", + currentValue: ({ cfg, accountId }) => + String(resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.port ?? ""), + shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1", + initialValue: ({ cfg, accountId, credentialValues }) => { + const resolved = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }); + const tls = credentialValues[TLS_FLAG] === "0" ? false : true; + const defaultPort = resolved.config.port ?? (tls ? 6697 : 6667); + return String(defaultPort); + }, + validate: ({ value }) => { + const parsed = Number.parseInt(String(value ?? "").trim(), 10); + return Number.isFinite(parsed) && parsed >= 1 && parsed <= 65535 + ? undefined + : "Use a port between 1 and 65535"; + }, + normalizeValue: ({ value }) => String(parsePort(String(value), 6697)), + applySet: async ({ cfg, accountId, value }) => + updateIrcAccountConfig(cfg as CoreConfig, accountId, { + enabled: true, + port: parsePort(String(value), 6697), + }), + }, + { + inputKey: "token", + message: "IRC nick", + currentValue: ({ cfg, accountId }) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.nick || undefined, + shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1", + validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"), + normalizeValue: ({ value }) => String(value).trim(), + applySet: async ({ cfg, accountId, value }) => + updateIrcAccountConfig(cfg as CoreConfig, accountId, { + enabled: true, + nick: value, + }), + }, + { + inputKey: "userId", + message: "IRC username", + currentValue: ({ cfg, accountId }) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.username || undefined, + shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1", + initialValue: ({ cfg, accountId, credentialValues }) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.username || + credentialValues.token || + "openclaw", + validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"), + normalizeValue: ({ value }) => String(value).trim(), + applySet: async ({ cfg, accountId, value }) => + updateIrcAccountConfig(cfg as CoreConfig, accountId, { + enabled: true, + username: value, + }), + }, + { + inputKey: "deviceName", + message: "IRC real name", + currentValue: ({ cfg, accountId }) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.realname || undefined, + shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1", + initialValue: ({ cfg, accountId }) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.realname || "OpenClaw", + validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"), + normalizeValue: ({ value }) => String(value).trim(), + applySet: async ({ cfg, accountId, value }) => + updateIrcAccountConfig(cfg as CoreConfig, accountId, { + enabled: true, + realname: value, + }), + }, + { + inputKey: "groupChannels", + message: "Auto-join IRC channels (optional, comma-separated)", + placeholder: "#openclaw, #ops", + required: false, + applyEmptyValue: true, + currentValue: ({ cfg, accountId }) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.channels?.join(", "), + shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1", + normalizeValue: ({ value }) => + parseListInput(String(value)) + .map((entry) => normalizeGroupEntry(entry)) + .filter((entry): entry is string => Boolean(entry && entry !== "*")) + .filter((entry) => isChannelTarget(entry)) + .join(", "), + applySet: async ({ cfg, accountId, value }) => { + const channels = parseListInput(String(value)) + .map((entry) => normalizeGroupEntry(entry)) + .filter((entry): entry is string => Boolean(entry && entry !== "*")) + .filter((entry) => isChannelTarget(entry)); + return updateIrcAccountConfig(cfg as CoreConfig, accountId, { + enabled: true, + channels: channels.length > 0 ? channels : undefined, + }); + }, + }, + ], + groupAccess: { + label: "IRC channels", + placeholder: "#openclaw, #ops, *", + currentPolicy: ({ cfg, accountId }) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.groupPolicy ?? "allowlist", + currentEntries: ({ cfg, accountId }) => + Object.keys(resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.groups ?? {}), + updatePrompt: ({ cfg, accountId }) => + Boolean(resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.groups), + setPolicy: ({ cfg, accountId, policy }) => + setIrcGroupAccess(cfg as CoreConfig, accountId, policy, []), + resolveAllowlist: async ({ entries }) => + [...new Set(entries.map((entry) => normalizeGroupEntry(entry)).filter(Boolean))] as string[], + applyAllowlist: ({ cfg, accountId, resolved }) => + setIrcGroupAccess(cfg as CoreConfig, accountId, "allowlist", resolved as string[]), + }, + allowFrom: { + helpTitle: "IRC allowlist", + helpLines: [ + "Allowlist IRC DMs by sender.", + "Examples:", + "- alice", + "- alice!ident@example.org", + "Multiple entries: comma-separated.", + ], + message: "IRC allowFrom (nick or nick!user@host)", + placeholder: "alice, bob!ident@example.org", + invalidWithoutCredentialNote: "Use an IRC nick or nick!user@host entry.", + parseId: (raw) => { + const normalized = normalizeIrcAllowEntry(raw); + return normalized || null; + }, + resolveEntries: async ({ entries }) => + entries.map((entry) => { + const normalized = normalizeIrcAllowEntry(entry); + return { + input: entry, + resolved: Boolean(normalized), + id: normalized || null, + }; + }), + apply: async ({ cfg, allowFrom }) => setIrcAllowFrom(cfg as CoreConfig, allowFrom), + }, + finalize: async ({ cfg, accountId, prompter }) => { + let next = cfg as CoreConfig; + + const resolvedAfterGroups = resolveIrcAccount({ cfg: next, accountId }); + if (resolvedAfterGroups.config.groupPolicy === "allowlist") { + const groupKeys = Object.keys(resolvedAfterGroups.config.groups ?? {}); + if (groupKeys.length > 0) { + const wantsMentions = await prompter.confirm({ + message: "Require @mention to reply in IRC channels?", + initialValue: true, + }); + if (!wantsMentions) { + const groups = resolvedAfterGroups.config.groups ?? {}; + const patched = Object.fromEntries( + Object.entries(groups).map(([key, value]) => [ + key, + { ...value, requireMention: false }, + ]), + ); + next = updateIrcAccountConfig(next, accountId, { groups: patched }); + } + } + } + + next = await promptIrcNickServConfig({ + cfg: next, + prompter, + accountId, + }); + return { cfg: next }; + }, + completionNote: { + title: "IRC next steps", + lines: [ + "Next: restart gateway and verify status.", + "Command: openclaw channels status --probe", + `Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`, + ], + }, + dmPolicy: ircDmPolicy, + disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), +}; From 8c71b36acbebd410b4806241aeee6bf7057afe05 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 17:56:40 -0700 Subject: [PATCH 075/943] refactor: move tlon to setup wizard --- extensions/tlon/src/channel.ts | 109 +-------- extensions/tlon/src/onboarding.ts | 209 ---------------- extensions/tlon/src/setup-surface.test.ts | 94 ++++++++ extensions/tlon/src/setup-surface.ts | 278 ++++++++++++++++++++++ 4 files changed, 375 insertions(+), 315 deletions(-) delete mode 100644 extensions/tlon/src/onboarding.ts create mode 100644 extensions/tlon/src/setup-surface.test.ts create mode 100644 extensions/tlon/src/setup-surface.ts diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index eb37c8d7f74..7a460a6adb8 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -3,18 +3,11 @@ import { configureClient } from "@tloncorp/api"; import type { ChannelOutboundAdapter, ChannelPlugin, - ChannelSetupInput, OpenClawConfig, } from "openclaw/plugin-sdk/tlon"; -import { - applyAccountNameToChannelSection, - DEFAULT_ACCOUNT_ID, - normalizeAccountId, -} from "openclaw/plugin-sdk/tlon"; -import { buildTlonAccountFields } from "./account-fields.js"; import { tlonChannelConfigSchema } from "./config-schema.js"; import { monitorTlonProvider } from "./monitor/index.js"; -import { tlonOnboardingAdapter } from "./onboarding.js"; +import { tlonSetupAdapter, tlonSetupWizard } from "./setup-surface.js"; import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js"; import { resolveTlonAccount, listTlonAccountIds } from "./types.js"; import { authenticate } from "./urbit/auth.js"; @@ -89,70 +82,6 @@ async function createHttpPokeApi(params: { const TLON_CHANNEL_ID = "tlon" as const; -type TlonSetupInput = ChannelSetupInput & { - ship?: string; - url?: string; - code?: string; - allowPrivateNetwork?: boolean; - groupChannels?: string[]; - dmAllowlist?: string[]; - autoDiscoverChannels?: boolean; - ownerShip?: string; -}; - -function applyTlonSetupConfig(params: { - cfg: OpenClawConfig; - accountId: string; - input: TlonSetupInput; -}): OpenClawConfig { - const { cfg, accountId, input } = params; - const useDefault = accountId === DEFAULT_ACCOUNT_ID; - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: "tlon", - accountId, - name: input.name, - }); - const base = namedConfig.channels?.tlon ?? {}; - - const payload = buildTlonAccountFields(input); - - if (useDefault) { - return { - ...namedConfig, - channels: { - ...namedConfig.channels, - tlon: { - ...base, - enabled: true, - ...payload, - }, - }, - }; - } - - return { - ...namedConfig, - channels: { - ...namedConfig.channels, - tlon: { - ...base, - enabled: base.enabled ?? true, - accounts: { - ...(base as { accounts?: Record }).accounts, - [accountId]: { - ...(base as { accounts?: Record> }).accounts?.[ - accountId - ], - enabled: true, - ...payload, - }, - }, - }, - }, - }; -} - type ResolvedTlonAccount = ReturnType; type ConfiguredTlonAccount = ResolvedTlonAccount & { ship: string; @@ -296,7 +225,8 @@ export const tlonPlugin: ChannelPlugin = { reply: true, threads: true, }, - onboarding: tlonOnboardingAdapter, + setup: tlonSetupAdapter, + setupWizard: tlonSetupWizard, reload: { configPrefixes: ["channels.tlon"] }, configSchema: tlonChannelConfigSchema, config: { @@ -374,39 +304,6 @@ export const tlonPlugin: ChannelPlugin = { url: account.url, }), }, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "tlon", - accountId, - name, - }), - validateInput: ({ cfg, accountId, input }) => { - const setupInput = input as TlonSetupInput; - const resolved = resolveTlonAccount(cfg, accountId ?? undefined); - const ship = setupInput.ship?.trim() || resolved.ship; - const url = setupInput.url?.trim() || resolved.url; - const code = setupInput.code?.trim() || resolved.code; - if (!ship) { - return "Tlon requires --ship."; - } - if (!url) { - return "Tlon requires --url."; - } - if (!code) { - return "Tlon requires --code."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => - applyTlonSetupConfig({ - cfg: cfg, - accountId, - input: input as TlonSetupInput, - }), - }, messaging: { normalizeTarget: (target) => { const parsed = parseTlonTarget(target); diff --git a/extensions/tlon/src/onboarding.ts b/extensions/tlon/src/onboarding.ts deleted file mode 100644 index 8207b190628..00000000000 --- a/extensions/tlon/src/onboarding.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/tlon"; -import { - formatDocsLink, - patchScopedAccountConfig, - resolveAccountIdForConfigure, - DEFAULT_ACCOUNT_ID, - type ChannelOnboardingAdapter, - type WizardPrompter, -} from "openclaw/plugin-sdk/tlon"; -import { buildTlonAccountFields } from "./account-fields.js"; -import type { TlonResolvedAccount } from "./types.js"; -import { listTlonAccountIds, resolveTlonAccount } from "./types.js"; -import { isBlockedUrbitHostname, validateUrbitBaseUrl } from "./urbit/base-url.js"; - -const channel = "tlon" as const; - -function isConfigured(account: TlonResolvedAccount): boolean { - return Boolean(account.ship && account.url && account.code); -} - -function applyAccountConfig(params: { - cfg: OpenClawConfig; - accountId: string; - input: { - name?: string; - ship?: string; - url?: string; - code?: string; - allowPrivateNetwork?: boolean; - groupChannels?: string[]; - dmAllowlist?: string[]; - autoDiscoverChannels?: boolean; - }; -}): OpenClawConfig { - const { cfg, accountId, input } = params; - const nextValues = { - enabled: true, - ...(input.name ? { name: input.name } : {}), - ...buildTlonAccountFields(input), - }; - if (accountId === DEFAULT_ACCOUNT_ID) { - return patchScopedAccountConfig({ - cfg, - channelKey: channel, - accountId, - patch: nextValues, - ensureChannelEnabled: false, - ensureAccountEnabled: false, - }); - } - return patchScopedAccountConfig({ - cfg, - channelKey: channel, - accountId, - patch: { enabled: cfg.channels?.tlon?.enabled ?? true }, - accountPatch: nextValues, - ensureChannelEnabled: false, - ensureAccountEnabled: false, - }); -} - -async function noteTlonHelp(prompter: WizardPrompter): Promise { - await prompter.note( - [ - "You need your Urbit ship URL and login code.", - "Example URL: https://your-ship-host", - "Example ship: ~sampel-palnet", - "If your ship URL is on a private network (LAN/localhost), you must explicitly allow it during setup.", - `Docs: ${formatDocsLink("/channels/tlon", "channels/tlon")}`, - ].join("\n"), - "Tlon setup", - ); -} - -function parseList(value: string): string[] { - return value - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); -} - -export const tlonOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const accountIds = listTlonAccountIds(cfg); - const configured = - accountIds.length > 0 - ? accountIds.some((accountId) => isConfigured(resolveTlonAccount(cfg, accountId))) - : isConfigured(resolveTlonAccount(cfg, DEFAULT_ACCOUNT_ID)); - - return { - channel, - configured, - statusLines: [`Tlon: ${configured ? "configured" : "needs setup"}`], - selectionHint: configured ? "configured" : "urbit messenger", - quickstartScore: configured ? 1 : 4, - }; - }, - configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { - const defaultAccountId = DEFAULT_ACCOUNT_ID; - const accountId = await resolveAccountIdForConfigure({ - cfg, - prompter, - label: "Tlon", - accountOverride: accountOverrides[channel], - shouldPromptAccountIds, - listAccountIds: listTlonAccountIds, - defaultAccountId, - }); - - const resolved = resolveTlonAccount(cfg, accountId); - await noteTlonHelp(prompter); - - const ship = await prompter.text({ - message: "Ship name", - placeholder: "~sampel-palnet", - initialValue: resolved.ship ?? undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - - const url = await prompter.text({ - message: "Ship URL", - placeholder: "https://your-ship-host", - initialValue: resolved.url ?? undefined, - validate: (value) => { - const next = validateUrbitBaseUrl(String(value ?? "")); - if (!next.ok) { - return next.error; - } - return undefined; - }, - }); - - const validatedUrl = validateUrbitBaseUrl(String(url).trim()); - if (!validatedUrl.ok) { - throw new Error(`Invalid URL: ${validatedUrl.error}`); - } - - let allowPrivateNetwork = resolved.allowPrivateNetwork ?? false; - if (isBlockedUrbitHostname(validatedUrl.hostname)) { - allowPrivateNetwork = await prompter.confirm({ - message: - "Ship URL looks like a private/internal host. Allow private network access? (SSRF risk)", - initialValue: allowPrivateNetwork, - }); - if (!allowPrivateNetwork) { - throw new Error("Refusing private/internal Ship URL without explicit approval"); - } - } - - const code = await prompter.text({ - message: "Login code", - placeholder: "lidlut-tabwed-pillex-ridrup", - initialValue: resolved.code ?? undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - - const wantsGroupChannels = await prompter.confirm({ - message: "Add group channels manually? (optional)", - initialValue: false, - }); - - let groupChannels: string[] | undefined; - if (wantsGroupChannels) { - const entry = await prompter.text({ - message: "Group channels (comma-separated)", - placeholder: "chat/~host-ship/general, chat/~host-ship/support", - }); - const parsed = parseList(String(entry ?? "")); - groupChannels = parsed.length > 0 ? parsed : undefined; - } - - const wantsAllowlist = await prompter.confirm({ - message: "Restrict DMs with an allowlist?", - initialValue: false, - }); - - let dmAllowlist: string[] | undefined; - if (wantsAllowlist) { - const entry = await prompter.text({ - message: "DM allowlist (comma-separated ship names)", - placeholder: "~zod, ~nec", - }); - const parsed = parseList(String(entry ?? "")); - dmAllowlist = parsed.length > 0 ? parsed : undefined; - } - - const autoDiscoverChannels = await prompter.confirm({ - message: "Enable auto-discovery of group channels?", - initialValue: resolved.autoDiscoverChannels ?? true, - }); - - const next = applyAccountConfig({ - cfg, - accountId, - input: { - ship: String(ship).trim(), - url: String(url).trim(), - code: String(code).trim(), - allowPrivateNetwork, - groupChannels, - dmAllowlist, - autoDiscoverChannels, - }, - }); - - return { cfg: next, accountId }; - }, -}; diff --git a/extensions/tlon/src/setup-surface.test.ts b/extensions/tlon/src/setup-surface.test.ts new file mode 100644 index 00000000000..bb638fc3018 --- /dev/null +++ b/extensions/tlon/src/setup-surface.test.ts @@ -0,0 +1,94 @@ +import type { OpenClawConfig, RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/tlon"; +import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { tlonPlugin } from "./channel.js"; + +const selectFirstOption = async (params: { options: Array<{ value: T }> }): Promise => { + const first = params.options[0]; + if (!first) { + throw new Error("no options"); + } + return first.value; +}; + +function createPrompter(overrides: Partial): WizardPrompter { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: selectFirstOption as WizardPrompter["select"], + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} + +const tlonConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: tlonPlugin, + wizard: tlonPlugin.setupWizard!, +}); + +describe("tlon setup wizard", () => { + it("configures ship, auth, and discovery settings", async () => { + const prompter = createPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Ship name") { + return "sampel-palnet"; + } + if (message === "Ship URL") { + return "https://urbit.example.com"; + } + if (message === "Login code") { + return "lidlut-tabwed-pillex-ridrup"; + } + if (message === "Group channels (comma-separated)") { + return "chat/~host-ship/general, chat/~host-ship/support"; + } + if (message === "DM allowlist (comma-separated ship names)") { + return "~zod, nec"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Add group channels manually? (optional)") { + return true; + } + if (message === "Restrict DMs with an allowlist?") { + return true; + } + if (message === "Enable auto-discovery of group channels?") { + return true; + } + return false; + }), + }); + + const runtime: RuntimeEnv = createRuntimeEnv(); + + const result = await tlonConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime, + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.tlon?.enabled).toBe(true); + expect(result.cfg.channels?.tlon?.ship).toBe("~sampel-palnet"); + expect(result.cfg.channels?.tlon?.url).toBe("https://urbit.example.com"); + expect(result.cfg.channels?.tlon?.code).toBe("lidlut-tabwed-pillex-ridrup"); + expect(result.cfg.channels?.tlon?.groupChannels).toEqual([ + "chat/~host-ship/general", + "chat/~host-ship/support", + ]); + expect(result.cfg.channels?.tlon?.dmAllowlist).toEqual(["~zod", "~nec"]); + expect(result.cfg.channels?.tlon?.autoDiscoverChannels).toBe(true); + expect(result.cfg.channels?.tlon?.allowPrivateNetwork).toBe(false); + }); +}); diff --git a/extensions/tlon/src/setup-surface.ts b/extensions/tlon/src/setup-surface.ts new file mode 100644 index 00000000000..4cf0d006ebd --- /dev/null +++ b/extensions/tlon/src/setup-surface.ts @@ -0,0 +1,278 @@ +import { + applyAccountNameToChannelSection, + patchScopedAccountConfig, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { buildTlonAccountFields } from "./account-fields.js"; +import { normalizeShip } from "./targets.js"; +import { listTlonAccountIds, resolveTlonAccount, type TlonResolvedAccount } from "./types.js"; +import { isBlockedUrbitHostname, validateUrbitBaseUrl } from "./urbit/base-url.js"; + +const channel = "tlon" as const; + +type TlonSetupInput = ChannelSetupInput & { + ship?: string; + url?: string; + code?: string; + allowPrivateNetwork?: boolean; + groupChannels?: string[]; + dmAllowlist?: string[]; + autoDiscoverChannels?: boolean; + ownerShip?: string; +}; + +function isConfigured(account: TlonResolvedAccount): boolean { + return Boolean(account.ship && account.url && account.code); +} + +function parseList(value: string): string[] { + return value + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function applyTlonSetupConfig(params: { + cfg: OpenClawConfig; + accountId: string; + input: TlonSetupInput; +}): OpenClawConfig { + const { cfg, accountId, input } = params; + const useDefault = accountId === DEFAULT_ACCOUNT_ID; + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const base = namedConfig.channels?.tlon ?? {}; + const payload = buildTlonAccountFields(input); + + if (useDefault) { + return { + ...namedConfig, + channels: { + ...namedConfig.channels, + tlon: { + ...base, + enabled: true, + ...payload, + }, + }, + }; + } + + return patchScopedAccountConfig({ + cfg: namedConfig, + channelKey: channel, + accountId, + patch: { enabled: base.enabled ?? true }, + accountPatch: { + enabled: true, + ...payload, + }, + ensureChannelEnabled: false, + ensureAccountEnabled: false, + }); +} + +export const tlonSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ cfg, accountId, input }) => { + const setupInput = input as TlonSetupInput; + const resolved = resolveTlonAccount(cfg, accountId ?? undefined); + const ship = setupInput.ship?.trim() || resolved.ship; + const url = setupInput.url?.trim() || resolved.url; + const code = setupInput.code?.trim() || resolved.code; + if (!ship) { + return "Tlon requires --ship."; + } + if (!url) { + return "Tlon requires --url."; + } + if (!code) { + return "Tlon requires --code."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => + applyTlonSetupConfig({ + cfg, + accountId, + input: input as TlonSetupInput, + }), +}; + +export const tlonSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + configuredHint: "configured", + unconfiguredHint: "urbit messenger", + configuredScore: 1, + unconfiguredScore: 4, + resolveConfigured: ({ cfg }) => { + const accountIds = listTlonAccountIds(cfg); + return accountIds.length > 0 + ? accountIds.some((accountId) => isConfigured(resolveTlonAccount(cfg, accountId))) + : isConfigured(resolveTlonAccount(cfg, DEFAULT_ACCOUNT_ID)); + }, + resolveStatusLines: ({ cfg }) => { + const accountIds = listTlonAccountIds(cfg); + const configured = + accountIds.length > 0 + ? accountIds.some((accountId) => isConfigured(resolveTlonAccount(cfg, accountId))) + : isConfigured(resolveTlonAccount(cfg, DEFAULT_ACCOUNT_ID)); + return [`Tlon: ${configured ? "configured" : "needs setup"}`]; + }, + }, + introNote: { + title: "Tlon setup", + lines: [ + "You need your Urbit ship URL and login code.", + "Example URL: https://your-ship-host", + "Example ship: ~sampel-palnet", + "If your ship URL is on a private network (LAN/localhost), you must explicitly allow it during setup.", + `Docs: ${formatDocsLink("/channels/tlon", "channels/tlon")}`, + ], + }, + credentials: [], + textInputs: [ + { + inputKey: "ship", + message: "Ship name", + placeholder: "~sampel-palnet", + currentValue: ({ cfg, accountId }) => resolveTlonAccount(cfg, accountId).ship ?? undefined, + validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"), + normalizeValue: ({ value }) => normalizeShip(String(value).trim()), + applySet: async ({ cfg, accountId, value }) => + applyTlonSetupConfig({ + cfg, + accountId, + input: { ship: value }, + }), + }, + { + inputKey: "url", + message: "Ship URL", + placeholder: "https://your-ship-host", + currentValue: ({ cfg, accountId }) => resolveTlonAccount(cfg, accountId).url ?? undefined, + validate: ({ value }) => { + const next = validateUrbitBaseUrl(String(value ?? "")); + if (!next.ok) { + return next.error; + } + return undefined; + }, + normalizeValue: ({ value }) => String(value).trim(), + applySet: async ({ cfg, accountId, value }) => + applyTlonSetupConfig({ + cfg, + accountId, + input: { url: value }, + }), + }, + { + inputKey: "code", + message: "Login code", + placeholder: "lidlut-tabwed-pillex-ridrup", + currentValue: ({ cfg, accountId }) => resolveTlonAccount(cfg, accountId).code ?? undefined, + validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"), + normalizeValue: ({ value }) => String(value).trim(), + applySet: async ({ cfg, accountId, value }) => + applyTlonSetupConfig({ + cfg, + accountId, + input: { code: value }, + }), + }, + ], + finalize: async ({ cfg, accountId, prompter }) => { + let next = cfg; + const resolved = resolveTlonAccount(next, accountId); + const validatedUrl = validateUrbitBaseUrl(resolved.url ?? ""); + if (!validatedUrl.ok) { + throw new Error(`Invalid URL: ${validatedUrl.error}`); + } + + let allowPrivateNetwork = resolved.allowPrivateNetwork ?? false; + if (isBlockedUrbitHostname(validatedUrl.hostname)) { + allowPrivateNetwork = await prompter.confirm({ + message: + "Ship URL looks like a private/internal host. Allow private network access? (SSRF risk)", + initialValue: allowPrivateNetwork, + }); + if (!allowPrivateNetwork) { + throw new Error("Refusing private/internal Ship URL without explicit approval"); + } + } + next = applyTlonSetupConfig({ + cfg: next, + accountId, + input: { allowPrivateNetwork }, + }); + + const currentGroups = resolved.groupChannels; + const wantsGroupChannels = await prompter.confirm({ + message: "Add group channels manually? (optional)", + initialValue: currentGroups.length > 0, + }); + if (wantsGroupChannels) { + const entry = await prompter.text({ + message: "Group channels (comma-separated)", + placeholder: "chat/~host-ship/general, chat/~host-ship/support", + initialValue: currentGroups.join(", ") || undefined, + }); + next = applyTlonSetupConfig({ + cfg: next, + accountId, + input: { groupChannels: parseList(String(entry ?? "")) }, + }); + } + + const currentAllowlist = resolved.dmAllowlist; + const wantsAllowlist = await prompter.confirm({ + message: "Restrict DMs with an allowlist?", + initialValue: currentAllowlist.length > 0, + }); + if (wantsAllowlist) { + const entry = await prompter.text({ + message: "DM allowlist (comma-separated ship names)", + placeholder: "~zod, ~nec", + initialValue: currentAllowlist.join(", ") || undefined, + }); + next = applyTlonSetupConfig({ + cfg: next, + accountId, + input: { + dmAllowlist: parseList(String(entry ?? "")).map((ship) => normalizeShip(ship)), + }, + }); + } + + const autoDiscoverChannels = await prompter.confirm({ + message: "Enable auto-discovery of group channels?", + initialValue: resolved.autoDiscoverChannels ?? true, + }); + next = applyTlonSetupConfig({ + cfg: next, + accountId, + input: { autoDiscoverChannels }, + }); + + return { cfg: next }; + }, +}; From 18e4e4677c5b49d460f2aa11a29e56e2ccc3a578 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 17:56:45 -0700 Subject: [PATCH 076/943] refactor: move googlechat to setup wizard --- extensions/googlechat/src/channel.ts | 67 +--- extensions/googlechat/src/onboarding.ts | 225 -------------- .../googlechat/src/setup-surface.test.ts | 68 +++++ extensions/googlechat/src/setup-surface.ts | 288 ++++++++++++++++++ 4 files changed, 359 insertions(+), 289 deletions(-) delete mode 100644 extensions/googlechat/src/onboarding.ts create mode 100644 extensions/googlechat/src/setup-surface.test.ts create mode 100644 extensions/googlechat/src/setup-surface.ts diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 3ae992d3e9e..ef8e92d8ce2 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -7,8 +7,6 @@ import { formatNormalizedAllowFromEntries, } from "openclaw/plugin-sdk/compat"; import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, buildComputedAccountStatusSnapshot, buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, @@ -16,9 +14,7 @@ import { getChatChannelMeta, listDirectoryGroupEntriesFromMapKeys, listDirectoryUserEntriesFromAllowFrom, - migrateBaseNameToDefaultAccount, missingTargetError, - normalizeAccountId, PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, resolveGoogleChatGroupRequireMention, @@ -40,8 +36,8 @@ import { import { googlechatMessageActions } from "./actions.js"; import { sendGoogleChatMessage, uploadGoogleChatAttachment, probeGoogleChat } from "./api.js"; import { resolveGoogleChatWebhookPath, startGoogleChatMonitor } from "./monitor.js"; -import { googlechatOnboardingAdapter } from "./onboarding.js"; import { getGoogleChatRuntime } from "./runtime.js"; +import { googlechatSetupAdapter, googlechatSetupWizard } from "./setup-surface.js"; import { isGoogleChatSpaceTarget, isGoogleChatUserTarget, @@ -136,7 +132,8 @@ const googlechatActions: ChannelMessageActionAdapter = { export const googlechatPlugin: ChannelPlugin = { id: "googlechat", meta: { ...meta }, - onboarding: googlechatOnboardingAdapter, + setup: googlechatSetupAdapter, + setupWizard: googlechatSetupWizard, pairing: { idLabel: "googlechatUserId", normalizeAllowEntry: (entry) => formatAllowFromEntry(entry), @@ -272,64 +269,6 @@ export const googlechatPlugin: ChannelPlugin = { }, }, actions: googlechatActions, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "googlechat", - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account."; - } - if (!input.useEnv && !input.token && !input.tokenFile) { - return "Google Chat requires --token (service account JSON) or --token-file."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "googlechat", - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "googlechat", - }) - : namedConfig; - const patch = input.useEnv - ? {} - : input.tokenFile - ? { serviceAccountFile: input.tokenFile } - : input.token - ? { serviceAccount: input.token } - : {}; - const audienceType = input.audienceType?.trim(); - const audience = input.audience?.trim(); - const webhookPath = input.webhookPath?.trim(); - const webhookUrl = input.webhookUrl?.trim(); - const configPatch = { - ...patch, - ...(audienceType ? { audienceType } : {}), - ...(audience ? { audience } : {}), - ...(webhookPath ? { webhookPath } : {}), - ...(webhookUrl ? { webhookUrl } : {}), - }; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: "googlechat", - accountId, - patch: configPatch, - }); - }, - }, outbound: { deliveryMode: "direct", chunker: (text, limit) => getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit), diff --git a/extensions/googlechat/src/onboarding.ts b/extensions/googlechat/src/onboarding.ts deleted file mode 100644 index f7708dd30b9..00000000000 --- a/extensions/googlechat/src/onboarding.ts +++ /dev/null @@ -1,225 +0,0 @@ -import type { OpenClawConfig, DmPolicy } from "openclaw/plugin-sdk/googlechat"; -import { - DEFAULT_ACCOUNT_ID, - applySetupAccountConfigPatch, - addWildcardAllowFrom, - formatDocsLink, - mergeAllowFromEntries, - resolveAccountIdForConfigure, - splitOnboardingEntries, - type ChannelOnboardingAdapter, - type ChannelOnboardingDmPolicy, - type WizardPrompter, - migrateBaseNameToDefaultAccount, -} from "openclaw/plugin-sdk/googlechat"; -import { - listGoogleChatAccountIds, - resolveDefaultGoogleChatAccountId, - resolveGoogleChatAccount, -} from "./accounts.js"; - -const channel = "googlechat" as const; - -const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT"; -const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE"; - -function setGoogleChatDmPolicy(cfg: OpenClawConfig, policy: DmPolicy) { - const allowFrom = - policy === "open" - ? addWildcardAllowFrom(cfg.channels?.["googlechat"]?.dm?.allowFrom) - : undefined; - return { - ...cfg, - channels: { - ...cfg.channels, - googlechat: { - ...cfg.channels?.["googlechat"], - dm: { - ...cfg.channels?.["googlechat"]?.dm, - policy, - ...(allowFrom ? { allowFrom } : {}), - }, - }, - }, - }; -} - -async function promptAllowFrom(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; -}): Promise { - const current = params.cfg.channels?.["googlechat"]?.dm?.allowFrom ?? []; - const entry = await params.prompter.text({ - message: "Google Chat allowFrom (users/ or raw email; avoid users/)", - placeholder: "users/123456789, name@example.com", - initialValue: current[0] ? String(current[0]) : undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - const parts = splitOnboardingEntries(String(entry)); - const unique = mergeAllowFromEntries(undefined, parts); - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - googlechat: { - ...params.cfg.channels?.["googlechat"], - enabled: true, - dm: { - ...params.cfg.channels?.["googlechat"]?.dm, - policy: "allowlist", - allowFrom: unique, - }, - }, - }, - }; -} - -const dmPolicy: ChannelOnboardingDmPolicy = { - label: "Google Chat", - channel, - policyKey: "channels.googlechat.dm.policy", - allowFromKey: "channels.googlechat.dm.allowFrom", - getCurrent: (cfg) => cfg.channels?.["googlechat"]?.dm?.policy ?? "pairing", - setPolicy: (cfg, policy) => setGoogleChatDmPolicy(cfg, policy), - promptAllowFrom, -}; - -async function promptCredentials(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId: string; -}): Promise { - const { cfg, prompter, accountId } = params; - const envReady = - accountId === DEFAULT_ACCOUNT_ID && - (Boolean(process.env[ENV_SERVICE_ACCOUNT]) || Boolean(process.env[ENV_SERVICE_ACCOUNT_FILE])); - if (envReady) { - const useEnv = await prompter.confirm({ - message: "Use GOOGLE_CHAT_SERVICE_ACCOUNT env vars?", - initialValue: true, - }); - if (useEnv) { - return applySetupAccountConfigPatch({ cfg, channelKey: channel, accountId, patch: {} }); - } - } - - const method = await prompter.select({ - message: "Google Chat auth method", - options: [ - { value: "file", label: "Service account JSON file" }, - { value: "inline", label: "Paste service account JSON" }, - ], - initialValue: "file", - }); - - if (method === "file") { - const path = await prompter.text({ - message: "Service account JSON path", - placeholder: "/path/to/service-account.json", - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - return applySetupAccountConfigPatch({ - cfg, - channelKey: channel, - accountId, - patch: { serviceAccountFile: String(path).trim() }, - }); - } - - const json = await prompter.text({ - message: "Service account JSON (single line)", - placeholder: '{"type":"service_account", ... }', - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - return applySetupAccountConfigPatch({ - cfg, - channelKey: channel, - accountId, - patch: { serviceAccount: String(json).trim() }, - }); -} - -async function promptAudience(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId: string; -}): Promise { - const account = resolveGoogleChatAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - const currentType = account.config.audienceType ?? "app-url"; - const currentAudience = account.config.audience ?? ""; - const audienceType = await params.prompter.select({ - message: "Webhook audience type", - options: [ - { value: "app-url", label: "App URL (recommended)" }, - { value: "project-number", label: "Project number" }, - ], - initialValue: currentType === "project-number" ? "project-number" : "app-url", - }); - const audience = await params.prompter.text({ - message: audienceType === "project-number" ? "Project number" : "App URL", - placeholder: audienceType === "project-number" ? "1234567890" : "https://your.host/googlechat", - initialValue: currentAudience || undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - return applySetupAccountConfigPatch({ - cfg: params.cfg, - channelKey: channel, - accountId: params.accountId, - patch: { audienceType, audience: String(audience).trim() }, - }); -} - -async function noteGoogleChatSetup(prompter: WizardPrompter) { - await prompter.note( - [ - "Google Chat apps use service-account auth and an HTTPS webhook.", - "Set the Chat API scopes in your service account and configure the Chat app URL.", - "Webhook verification requires audience type + audience value.", - `Docs: ${formatDocsLink("/channels/googlechat", "channels/googlechat")}`, - ].join("\n"), - "Google Chat setup", - ); -} - -export const googlechatOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - dmPolicy, - getStatus: async ({ cfg }) => { - const configured = listGoogleChatAccountIds(cfg).some( - (accountId) => resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none", - ); - return { - channel, - configured, - statusLines: [`Google Chat: ${configured ? "configured" : "needs service account"}`], - selectionHint: configured ? "configured" : "needs auth", - }; - }, - configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { - const defaultAccountId = resolveDefaultGoogleChatAccountId(cfg); - const accountId = await resolveAccountIdForConfigure({ - cfg, - prompter, - label: "Google Chat", - accountOverride: accountOverrides["googlechat"], - shouldPromptAccountIds, - listAccountIds: listGoogleChatAccountIds, - defaultAccountId, - }); - - let next = cfg; - await noteGoogleChatSetup(prompter); - next = await promptCredentials({ cfg: next, prompter, accountId }); - next = await promptAudience({ cfg: next, prompter, accountId }); - - const namedConfig = migrateBaseNameToDefaultAccount({ - cfg: next, - channelKey: "googlechat", - }); - - return { cfg: namedConfig, accountId }; - }, -}; diff --git a/extensions/googlechat/src/setup-surface.test.ts b/extensions/googlechat/src/setup-surface.test.ts new file mode 100644 index 00000000000..ab09435f67e --- /dev/null +++ b/extensions/googlechat/src/setup-surface.test.ts @@ -0,0 +1,68 @@ +import type { OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk/googlechat"; +import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { googlechatPlugin } from "./channel.js"; + +const selectFirstOption = async (params: { options: Array<{ value: T }> }): Promise => { + const first = params.options[0]; + if (!first) { + throw new Error("no options"); + } + return first.value; +}; + +function createPrompter(overrides: Partial): WizardPrompter { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: selectFirstOption as WizardPrompter["select"], + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} + +const googlechatConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: googlechatPlugin, + wizard: googlechatPlugin.setupWizard!, +}); + +describe("googlechat setup wizard", () => { + it("configures service-account auth and webhook audience", async () => { + const prompter = createPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Service account JSON path") { + return "/tmp/googlechat-service-account.json"; + } + if (message === "App URL") { + return "https://example.com/googlechat"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + }); + + const runtime = createRuntimeEnv(); + + const result = await googlechatConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime, + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.googlechat?.enabled).toBe(true); + expect(result.cfg.channels?.googlechat?.serviceAccountFile).toBe( + "/tmp/googlechat-service-account.json", + ); + expect(result.cfg.channels?.googlechat?.audienceType).toBe("app-url"); + expect(result.cfg.channels?.googlechat?.audience).toBe("https://example.com/googlechat"); + }); +}); diff --git a/extensions/googlechat/src/setup-surface.ts b/extensions/googlechat/src/setup-surface.ts new file mode 100644 index 00000000000..e812561f674 --- /dev/null +++ b/extensions/googlechat/src/setup-surface.ts @@ -0,0 +1,288 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + addWildcardAllowFrom, + mergeAllowFromEntries, + setTopLevelChannelDmPolicyWithAllowFrom, + splitOnboardingEntries, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DmPolicy } from "../../../src/config/types.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { + listGoogleChatAccountIds, + resolveDefaultGoogleChatAccountId, + resolveGoogleChatAccount, +} from "./accounts.js"; + +const channel = "googlechat" as const; +const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT"; +const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE"; +const USE_ENV_FLAG = "__googlechatUseEnv"; +const AUTH_METHOD_FLAG = "__googlechatAuthMethod"; + +function setGoogleChatDmPolicy(cfg: OpenClawConfig, policy: DmPolicy) { + const allowFrom = + policy === "open" ? addWildcardAllowFrom(cfg.channels?.googlechat?.dm?.allowFrom) : undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + googlechat: { + ...cfg.channels?.googlechat, + dm: { + ...cfg.channels?.googlechat?.dm, + policy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }, + }; +} + +async function promptAllowFrom(params: { + cfg: OpenClawConfig; + prompter: Parameters>[0]["prompter"]; +}): Promise { + const current = params.cfg.channels?.googlechat?.dm?.allowFrom ?? []; + const entry = await params.prompter.text({ + message: "Google Chat allowFrom (users/ or raw email; avoid users/)", + placeholder: "users/123456789, name@example.com", + initialValue: current[0] ? String(current[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + const parts = splitOnboardingEntries(String(entry)); + const unique = mergeAllowFromEntries(undefined, parts); + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + googlechat: { + ...params.cfg.channels?.googlechat, + enabled: true, + dm: { + ...params.cfg.channels?.googlechat?.dm, + policy: "allowlist", + allowFrom: unique, + }, + }, + }, + }; +} + +const googlechatDmPolicy: ChannelOnboardingDmPolicy = { + label: "Google Chat", + channel, + policyKey: "channels.googlechat.dm.policy", + allowFromKey: "channels.googlechat.dm.allowFrom", + getCurrent: (cfg) => cfg.channels?.googlechat?.dm?.policy ?? "pairing", + setPolicy: (cfg, policy) => setGoogleChatDmPolicy(cfg, policy), + promptAllowFrom, +}; + +export const googlechatSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account."; + } + if (!input.useEnv && !input.token && !input.tokenFile) { + return "Google Chat requires --token (service account JSON) or --token-file."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + const patch = input.useEnv + ? {} + : input.tokenFile + ? { serviceAccountFile: input.tokenFile } + : input.token + ? { serviceAccount: input.token } + : {}; + const audienceType = input.audienceType?.trim(); + const audience = input.audience?.trim(); + const webhookPath = input.webhookPath?.trim(); + const webhookUrl = input.webhookUrl?.trim(); + return applySetupAccountConfigPatch({ + cfg: next, + channelKey: channel, + accountId, + patch: { + ...patch, + ...(audienceType ? { audienceType } : {}), + ...(audience ? { audience } : {}), + ...(webhookPath ? { webhookPath } : {}), + ...(webhookUrl ? { webhookUrl } : {}), + }, + }); + }, +}; + +export const googlechatSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs service account", + configuredHint: "configured", + unconfiguredHint: "needs auth", + resolveConfigured: ({ cfg }) => + listGoogleChatAccountIds(cfg).some( + (accountId) => resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none", + ), + resolveStatusLines: ({ cfg }) => { + const configured = listGoogleChatAccountIds(cfg).some( + (accountId) => resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none", + ); + return [`Google Chat: ${configured ? "configured" : "needs service account"}`]; + }, + }, + introNote: { + title: "Google Chat setup", + lines: [ + "Google Chat apps use service-account auth and an HTTPS webhook.", + "Set the Chat API scopes in your service account and configure the Chat app URL.", + "Webhook verification requires audience type + audience value.", + `Docs: ${formatDocsLink("/channels/googlechat", "googlechat")}`, + ], + }, + prepare: async ({ cfg, accountId, credentialValues, prompter }) => { + const envReady = + accountId === DEFAULT_ACCOUNT_ID && + (Boolean(process.env[ENV_SERVICE_ACCOUNT]) || Boolean(process.env[ENV_SERVICE_ACCOUNT_FILE])); + if (envReady) { + const useEnv = await prompter.confirm({ + message: "Use GOOGLE_CHAT_SERVICE_ACCOUNT env vars?", + initialValue: true, + }); + if (useEnv) { + return { + cfg: applySetupAccountConfigPatch({ + cfg, + channelKey: channel, + accountId, + patch: {}, + }), + credentialValues: { + ...credentialValues, + [USE_ENV_FLAG]: "1", + }, + }; + } + } + + const method = await prompter.select({ + message: "Google Chat auth method", + options: [ + { value: "file", label: "Service account JSON file" }, + { value: "inline", label: "Paste service account JSON" }, + ], + initialValue: "file", + }); + + return { + credentialValues: { + ...credentialValues, + [USE_ENV_FLAG]: "0", + [AUTH_METHOD_FLAG]: String(method), + }, + }; + }, + credentials: [], + textInputs: [ + { + inputKey: "tokenFile", + message: "Service account JSON path", + placeholder: "/path/to/service-account.json", + shouldPrompt: ({ credentialValues }) => + credentialValues[USE_ENV_FLAG] !== "1" && credentialValues[AUTH_METHOD_FLAG] === "file", + validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"), + normalizeValue: ({ value }) => String(value).trim(), + applySet: async ({ cfg, accountId, value }) => + applySetupAccountConfigPatch({ + cfg, + channelKey: channel, + accountId, + patch: { serviceAccountFile: value }, + }), + }, + { + inputKey: "token", + message: "Service account JSON (single line)", + placeholder: '{"type":"service_account", ... }', + shouldPrompt: ({ credentialValues }) => + credentialValues[USE_ENV_FLAG] !== "1" && credentialValues[AUTH_METHOD_FLAG] === "inline", + validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"), + normalizeValue: ({ value }) => String(value).trim(), + applySet: async ({ cfg, accountId, value }) => + applySetupAccountConfigPatch({ + cfg, + channelKey: channel, + accountId, + patch: { serviceAccount: value }, + }), + }, + ], + finalize: async ({ cfg, accountId, prompter }) => { + const account = resolveGoogleChatAccount({ + cfg, + accountId, + }); + const audienceType = await prompter.select({ + message: "Webhook audience type", + options: [ + { value: "app-url", label: "App URL (recommended)" }, + { value: "project-number", label: "Project number" }, + ], + initialValue: account.config.audienceType === "project-number" ? "project-number" : "app-url", + }); + const audience = await prompter.text({ + message: audienceType === "project-number" ? "Project number" : "App URL", + placeholder: + audienceType === "project-number" ? "1234567890" : "https://your.host/googlechat", + initialValue: account.config.audience || undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + return { + cfg: migrateBaseNameToDefaultAccount({ + cfg: applySetupAccountConfigPatch({ + cfg, + channelKey: channel, + accountId, + patch: { + audienceType, + audience: String(audience).trim(), + }, + }), + channelKey: channel, + }), + }; + }, + dmPolicy: googlechatDmPolicy, +}; From a78b83472e60435efd2f0178229f55dc6c26f1fa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 17:56:49 -0700 Subject: [PATCH 077/943] refactor: expose setup wizard sdk surfaces --- src/plugin-sdk/googlechat.ts | 9 ++++----- src/plugin-sdk/irc.ts | 12 ++++++------ src/plugin-sdk/subpaths.test.ts | 20 ++++++++++++++++++++ src/plugin-sdk/tlon.ts | 7 ++----- 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/plugin-sdk/googlechat.ts b/src/plugin-sdk/googlechat.ts index 17bc36daab1..e6e9aaefb1c 100644 --- a/src/plugin-sdk/googlechat.ts +++ b/src/plugin-sdk/googlechat.ts @@ -24,15 +24,10 @@ export { createAccountStatusSink, runPassiveAccountLifecycle } from "./channel-l export { resolveGoogleChatGroupRequireMention } from "../channels/plugins/group-mentions.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; export { addWildcardAllowFrom, mergeAllowFromEntries, promptAccountId, - resolveAccountIdForConfigure, splitOnboardingEntries, setTopLevelChannelDmPolicyWithAllowFrom, } from "../channels/plugins/onboarding/helpers.js"; @@ -72,6 +67,10 @@ export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.j export { resolveDmGroupAccessWithLists } from "../security/dm-policy-shared.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; +export { + googlechatSetupAdapter, + googlechatSetupWizard, +} from "../../extensions/googlechat/src/setup-surface.js"; export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "./inbound-envelope.js"; export { createScopedPairingAccess } from "./pairing-access.js"; export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; diff --git a/src/plugin-sdk/irc.ts b/src/plugin-sdk/irc.ts index 2ef8602421f..472c46ea2e5 100644 --- a/src/plugin-sdk/irc.ts +++ b/src/plugin-sdk/irc.ts @@ -13,15 +13,9 @@ export { formatPairingApproveHint, parseOptionalDelimitedEntries, } from "../channels/plugins/helpers.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; -export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js"; export { addWildcardAllowFrom, promptAccountId, - resolveAccountIdForConfigure, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, } from "../channels/plugins/onboarding/helpers.js"; @@ -65,6 +59,11 @@ export type { OpenClawPluginApi } from "../plugins/types.js"; export { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; export type { RuntimeEnv } from "../runtime.js"; export { createAccountStatusSink, runPassiveAccountLifecycle } from "./channel-lifecycle.js"; +export { + listIrcAccountIds, + resolveDefaultIrcAccountId, + resolveIrcAccount, +} from "../../extensions/irc/src/accounts.js"; export { readStoreAllowFromForDmPolicy, resolveEffectiveAllowFromLists, @@ -74,6 +73,7 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { createScopedPairingAccess } from "./pairing-access.js"; export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; export { dispatchInboundReplyWithBase } from "./inbound-reply-dispatch.js"; +export { ircSetupAdapter, ircSetupWizard } from "../../extensions/irc/src/setup-surface.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; export { createNormalizedOutboundDeliverer, diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 8068f342b0e..996c6b27188 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -90,6 +90,13 @@ describe("plugin-sdk subpath exports", () => { expect(typeof imessageSdk.imessageSetupAdapter).toBe("object"); }); + it("exports IRC helpers", async () => { + const ircSdk = await import("openclaw/plugin-sdk/irc"); + expect(typeof ircSdk.resolveIrcAccount).toBe("function"); + expect(typeof ircSdk.ircSetupWizard).toBe("object"); + expect(typeof ircSdk.ircSetupAdapter).toBe("object"); + }); + it("exports WhatsApp helpers", () => { // WhatsApp-specific functions (resolveWhatsAppAccount, whatsappOnboardingAdapter) moved to extensions/whatsapp/src/ expect(typeof whatsappSdk.WhatsAppConfigSchema).toBe("object"); @@ -108,6 +115,19 @@ describe("plugin-sdk subpath exports", () => { expect(typeof msteamsSdk.loadOutboundMediaFromUrl).toBe("function"); }); + it("exports Google Chat helpers", async () => { + const googlechatSdk = await import("openclaw/plugin-sdk/googlechat"); + expect(typeof googlechatSdk.googlechatSetupWizard).toBe("object"); + expect(typeof googlechatSdk.googlechatSetupAdapter).toBe("object"); + }); + + it("exports Tlon helpers", async () => { + const tlonSdk = await import("openclaw/plugin-sdk/tlon"); + expect(typeof tlonSdk.fetchWithSsrFGuard).toBe("function"); + expect(typeof tlonSdk.tlonSetupWizard).toBe("object"); + expect(typeof tlonSdk.tlonSetupAdapter).toBe("object"); + }); + it("exports acpx helpers", async () => { const acpxSdk = await import("openclaw/plugin-sdk/acpx"); expect(typeof acpxSdk.listKnownProviderAuthEnvVarNames).toBe("function"); diff --git a/src/plugin-sdk/tlon.ts b/src/plugin-sdk/tlon.ts index 06ddcc8e256..9a39493cac2 100644 --- a/src/plugin-sdk/tlon.ts +++ b/src/plugin-sdk/tlon.ts @@ -3,11 +3,7 @@ export type { ReplyPayload } from "../auto-reply/types.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export type { ChannelOnboardingAdapter } from "../channels/plugins/onboarding-types.js"; -export { - promptAccountId, - resolveAccountIdForConfigure, -} from "../channels/plugins/onboarding/helpers.js"; +export { promptAccountId } from "../channels/plugins/onboarding/helpers.js"; export { applyAccountNameToChannelSection, patchScopedAccountConfig, @@ -32,3 +28,4 @@ export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { createLoggerBackedRuntime } from "./runtime.js"; +export { tlonSetupAdapter, tlonSetupWizard } from "../../extensions/tlon/src/setup-surface.js"; From 66a8c257b9e0a7d971ae5dbd70f3806eb4ae8aa9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 17:56:32 -0700 Subject: [PATCH 078/943] Feishu: lazy-load runtime-heavy channel paths --- extensions/feishu/src/channel.runtime.ts | 6 ++ extensions/feishu/src/channel.ts | 79 ++++++++++++++++++----- extensions/feishu/src/directory.static.ts | 61 +++++++++++++++++ extensions/feishu/src/directory.ts | 65 ++----------------- 4 files changed, 137 insertions(+), 74 deletions(-) create mode 100644 extensions/feishu/src/channel.runtime.ts create mode 100644 extensions/feishu/src/directory.static.ts diff --git a/extensions/feishu/src/channel.runtime.ts b/extensions/feishu/src/channel.runtime.ts new file mode 100644 index 00000000000..8068fb350d3 --- /dev/null +++ b/extensions/feishu/src/channel.runtime.ts @@ -0,0 +1,6 @@ +export { listFeishuDirectoryGroupsLive, listFeishuDirectoryPeersLive } from "./directory.js"; +export { feishuOnboardingAdapter } from "./onboarding.js"; +export { feishuOutbound } from "./outbound.js"; +export { probeFeishu } from "./probe.js"; +export { addReactionFeishu, listReactionsFeishu, removeReactionFeishu } from "./reactions.js"; +export { sendCardFeishu, sendMessageFeishu } from "./send.js"; diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 3baa7c916a2..17f3e5cc580 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -22,18 +22,9 @@ import { resolveDefaultFeishuAccountId, } from "./accounts.js"; import { FeishuConfigSchema } from "./config-schema.js"; -import { - listFeishuDirectoryPeers, - listFeishuDirectoryGroups, - listFeishuDirectoryPeersLive, - listFeishuDirectoryGroupsLive, -} from "./directory.js"; -import { feishuOnboardingAdapter } from "./onboarding.js"; -import { feishuOutbound } from "./outbound.js"; +import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js"; import { resolveFeishuGroupToolPolicy } from "./policy.js"; -import { probeFeishu } from "./probe.js"; -import { addReactionFeishu, listReactionsFeishu, removeReactionFeishu } from "./reactions.js"; -import { sendCardFeishu, sendMessageFeishu } from "./send.js"; +import { getFeishuRuntime } from "./runtime.js"; import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js"; import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js"; @@ -48,6 +39,47 @@ const meta: ChannelMeta = { order: 70, }; +async function loadFeishuChannelRuntime() { + return await import("./channel.runtime.js"); +} + +const feishuOnboarding = { + channel: "feishu", + getStatus: async (ctx) => + (await loadFeishuChannelRuntime()).feishuOnboardingAdapter.getStatus(ctx), + configure: async (ctx) => + (await loadFeishuChannelRuntime()).feishuOnboardingAdapter.configure(ctx), + dmPolicy: { + label: "Feishu", + channel: "feishu", + policyKey: "channels.feishu.dmPolicy", + allowFromKey: "channels.feishu.allowFrom", + getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => ({ + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + dmPolicy: policy, + }, + }, + }), + promptAllowFrom: async (cfg, prompter) => + (await loadFeishuChannelRuntime()).feishuOnboardingAdapter.dmPolicy!.promptAllowFrom({ + cfg, + prompter, + }), + }, + disable: (cfg) => ({ + ...cfg, + channels: { + ...cfg.channels, + feishu: { ...cfg.channels?.feishu, enabled: false }, + }, + }), +} satisfies ChannelPlugin["onboarding"]; + function setFeishuNamedAccountEnabled( cfg: ClawdbotConfig, accountId: string, @@ -107,6 +139,7 @@ export const feishuPlugin: ChannelPlugin = { idLabel: "feishuUserId", normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ""), notifyApproval: async ({ cfg, id }) => { + const { sendMessageFeishu } = await loadFeishuChannelRuntime(); await sendMessageFeishu({ cfg, to: id, @@ -254,6 +287,7 @@ export const feishuPlugin: ChannelPlugin = { typeof ctx.params.replyTo === "string" ? ctx.params.replyTo.trim() || undefined : undefined; + const { sendCardFeishu } = await loadFeishuChannelRuntime(); const result = await sendCardFeishu({ cfg: ctx.cfg, to, @@ -287,6 +321,7 @@ export const feishuPlugin: ChannelPlugin = { if (!emoji) { throw new Error("Emoji is required to remove a Feishu reaction."); } + const { listReactionsFeishu, removeReactionFeishu } = await loadFeishuChannelRuntime(); const matches = await listReactionsFeishu({ cfg: ctx.cfg, messageId, @@ -321,6 +356,7 @@ export const feishuPlugin: ChannelPlugin = { "Emoji is required to add a Feishu reaction. Set clearAll=true to remove all bot reactions.", ); } + const { listReactionsFeishu, removeReactionFeishu } = await loadFeishuChannelRuntime(); const reactions = await listReactionsFeishu({ cfg: ctx.cfg, messageId, @@ -341,6 +377,7 @@ export const feishuPlugin: ChannelPlugin = { details: { ok: true, removed }, }; } + const { addReactionFeishu } = await loadFeishuChannelRuntime(); await addReactionFeishu({ cfg: ctx.cfg, messageId, @@ -361,6 +398,7 @@ export const feishuPlugin: ChannelPlugin = { if (!messageId) { throw new Error("Feishu reactions lookup requires messageId."); } + const { listReactionsFeishu } = await loadFeishuChannelRuntime(); const reactions = await listReactionsFeishu({ cfg: ctx.cfg, messageId, @@ -411,7 +449,7 @@ export const feishuPlugin: ChannelPlugin = { return setFeishuNamedAccountEnabled(cfg, accountId, true); }, }, - onboarding: feishuOnboardingAdapter, + onboarding: feishuOnboarding, messaging: { normalizeTarget: (raw) => normalizeFeishuTarget(raw) ?? undefined, targetResolver: { @@ -436,28 +474,37 @@ export const feishuPlugin: ChannelPlugin = { accountId: accountId ?? undefined, }), listPeersLive: async ({ cfg, query, limit, accountId }) => - listFeishuDirectoryPeersLive({ + (await loadFeishuChannelRuntime()).listFeishuDirectoryPeersLive({ cfg, query: query ?? undefined, limit: limit ?? undefined, accountId: accountId ?? undefined, }), listGroupsLive: async ({ cfg, query, limit, accountId }) => - listFeishuDirectoryGroupsLive({ + (await loadFeishuChannelRuntime()).listFeishuDirectoryGroupsLive({ cfg, query: query ?? undefined, limit: limit ?? undefined, accountId: accountId ?? undefined, }), }, - outbound: feishuOutbound, + outbound: { + deliveryMode: "direct", + chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + sendText: async (params) => (await loadFeishuChannelRuntime()).feishuOutbound.sendText!(params), + sendMedia: async (params) => + (await loadFeishuChannelRuntime()).feishuOutbound.sendMedia!(params), + }, status: { defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }), buildChannelSummary: ({ snapshot }) => buildProbeChannelStatusSummary(snapshot, { port: snapshot.port ?? null, }), - probeAccount: async ({ account }) => await probeFeishu(account), + probeAccount: async ({ account }) => + await (await loadFeishuChannelRuntime()).probeFeishu(account), buildAccountSnapshot: ({ account, runtime, probe }) => ({ accountId: account.accountId, enabled: account.enabled, diff --git a/extensions/feishu/src/directory.static.ts b/extensions/feishu/src/directory.static.ts new file mode 100644 index 00000000000..b79e4e94f77 --- /dev/null +++ b/extensions/feishu/src/directory.static.ts @@ -0,0 +1,61 @@ +import { + listDirectoryGroupEntriesFromMapKeysAndAllowFrom, + listDirectoryUserEntriesFromAllowFromAndMapKeys, +} from "openclaw/plugin-sdk/compat"; +import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; +import { resolveFeishuAccount } from "./accounts.js"; +import { normalizeFeishuTarget } from "./targets.js"; + +export type FeishuDirectoryPeer = { + kind: "user"; + id: string; + name?: string; +}; + +export type FeishuDirectoryGroup = { + kind: "group"; + id: string; + name?: string; +}; + +function toFeishuDirectoryPeers(ids: string[]): FeishuDirectoryPeer[] { + return ids.map((id) => ({ kind: "user", id })); +} + +function toFeishuDirectoryGroups(ids: string[]): FeishuDirectoryGroup[] { + return ids.map((id) => ({ kind: "group", id })); +} + +export async function listFeishuDirectoryPeers(params: { + cfg: ClawdbotConfig; + query?: string; + limit?: number; + accountId?: string; +}): Promise { + const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); + const entries = listDirectoryUserEntriesFromAllowFromAndMapKeys({ + allowFrom: account.config.allowFrom, + map: account.config.dms, + query: params.query, + limit: params.limit, + normalizeAllowFromId: (entry) => normalizeFeishuTarget(entry) ?? entry, + normalizeMapKeyId: (entry) => normalizeFeishuTarget(entry) ?? entry, + }); + return toFeishuDirectoryPeers(entries.map((entry) => entry.id)); +} + +export async function listFeishuDirectoryGroups(params: { + cfg: ClawdbotConfig; + query?: string; + limit?: number; + accountId?: string; +}): Promise { + const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); + const entries = listDirectoryGroupEntriesFromMapKeysAndAllowFrom({ + groups: account.config.groups, + allowFrom: account.config.groupAllowFrom, + query: params.query, + limit: params.limit, + }); + return toFeishuDirectoryGroups(entries.map((entry) => entry.id)); +} diff --git a/extensions/feishu/src/directory.ts b/extensions/feishu/src/directory.ts index 4b5ca584a99..c6366990204 100644 --- a/extensions/feishu/src/directory.ts +++ b/extensions/feishu/src/directory.ts @@ -1,65 +1,14 @@ -import { - listDirectoryGroupEntriesFromMapKeysAndAllowFrom, - listDirectoryUserEntriesFromAllowFromAndMapKeys, -} from "openclaw/plugin-sdk/compat"; import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; -import { normalizeFeishuTarget } from "./targets.js"; +import { + listFeishuDirectoryGroups, + listFeishuDirectoryPeers, + type FeishuDirectoryGroup, + type FeishuDirectoryPeer, +} from "./directory.static.js"; -export type FeishuDirectoryPeer = { - kind: "user"; - id: string; - name?: string; -}; - -export type FeishuDirectoryGroup = { - kind: "group"; - id: string; - name?: string; -}; - -function toFeishuDirectoryPeers(ids: string[]): FeishuDirectoryPeer[] { - return ids.map((id) => ({ kind: "user", id })); -} - -function toFeishuDirectoryGroups(ids: string[]): FeishuDirectoryGroup[] { - return ids.map((id) => ({ kind: "group", id })); -} - -export async function listFeishuDirectoryPeers(params: { - cfg: ClawdbotConfig; - query?: string; - limit?: number; - accountId?: string; -}): Promise { - const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); - const entries = listDirectoryUserEntriesFromAllowFromAndMapKeys({ - allowFrom: account.config.allowFrom, - map: account.config.dms, - query: params.query, - limit: params.limit, - normalizeAllowFromId: (entry) => normalizeFeishuTarget(entry) ?? entry, - normalizeMapKeyId: (entry) => normalizeFeishuTarget(entry) ?? entry, - }); - return toFeishuDirectoryPeers(entries.map((entry) => entry.id)); -} - -export async function listFeishuDirectoryGroups(params: { - cfg: ClawdbotConfig; - query?: string; - limit?: number; - accountId?: string; -}): Promise { - const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); - const entries = listDirectoryGroupEntriesFromMapKeysAndAllowFrom({ - groups: account.config.groups, - allowFrom: account.config.groupAllowFrom, - query: params.query, - limit: params.limit, - }); - return toFeishuDirectoryGroups(entries.map((entry) => entry.id)); -} +export { listFeishuDirectoryGroups, listFeishuDirectoryPeers } from "./directory.static.js"; export async function listFeishuDirectoryPeersLive(params: { cfg: ClawdbotConfig; From ae6ee73097e19b9a3cd0eb5ab32b5ac7998dcb45 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 18:01:04 -0700 Subject: [PATCH 079/943] Google Chat: lazy-load runtime-heavy channel paths --- extensions/googlechat/src/channel.runtime.ts | 2 ++ extensions/googlechat/src/channel.ts | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 extensions/googlechat/src/channel.runtime.ts diff --git a/extensions/googlechat/src/channel.runtime.ts b/extensions/googlechat/src/channel.runtime.ts new file mode 100644 index 00000000000..fdf060f9fd4 --- /dev/null +++ b/extensions/googlechat/src/channel.runtime.ts @@ -0,0 +1,2 @@ +export { probeGoogleChat, sendGoogleChatMessage, uploadGoogleChatAttachment } from "./api.js"; +export { resolveGoogleChatWebhookPath, startGoogleChatMonitor } from "./monitor.js"; diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index ef8e92d8ce2..9ea172091f1 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -34,8 +34,6 @@ import { type ResolvedGoogleChatAccount, } from "./accounts.js"; import { googlechatMessageActions } from "./actions.js"; -import { sendGoogleChatMessage, uploadGoogleChatAttachment, probeGoogleChat } from "./api.js"; -import { resolveGoogleChatWebhookPath, startGoogleChatMonitor } from "./monitor.js"; import { getGoogleChatRuntime } from "./runtime.js"; import { googlechatSetupAdapter, googlechatSetupWizard } from "./setup-surface.js"; import { @@ -47,6 +45,10 @@ import { const meta = getChatChannelMeta("googlechat"); +async function loadGoogleChatChannelRuntime() { + return await import("./channel.runtime.js"); +} + const formatAllowFromEntry = (entry: string) => entry .trim() @@ -145,6 +147,7 @@ export const googlechatPlugin: ChannelPlugin = { const user = normalizeGoogleChatTarget(id) ?? id; const target = isGoogleChatUserTarget(user) ? user : `users/${user}`; const space = await resolveGoogleChatOutboundSpace({ account, target }); + const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime(); await sendGoogleChatMessage({ account, space, @@ -300,6 +303,7 @@ export const googlechatPlugin: ChannelPlugin = { }); const space = await resolveGoogleChatOutboundSpace({ account, target: to }); const thread = (threadId ?? replyToId ?? undefined) as string | undefined; + const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime(); const result = await sendGoogleChatMessage({ account, space, @@ -353,6 +357,8 @@ export const googlechatPlugin: ChannelPlugin = { maxBytes: effectiveMaxBytes, localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined, }); + const { sendGoogleChatMessage, uploadGoogleChatAttachment } = + await loadGoogleChatChannelRuntime(); const upload = await uploadGoogleChatAttachment({ account, space, @@ -421,7 +427,8 @@ export const googlechatPlugin: ChannelPlugin = { webhookPath: snapshot.webhookPath ?? null, webhookUrl: snapshot.webhookUrl ?? null, }), - probeAccount: async ({ account }) => probeGoogleChat(account), + probeAccount: async ({ account }) => + (await loadGoogleChatChannelRuntime()).probeGoogleChat(account), buildAccountSnapshot: ({ account, runtime, probe }) => { const base = buildComputedAccountStatusSnapshot({ accountId: account.accountId, @@ -450,6 +457,8 @@ export const googlechatPlugin: ChannelPlugin = { setStatus: ctx.setStatus, }); ctx.log?.info(`[${account.accountId}] starting Google Chat webhook`); + const { resolveGoogleChatWebhookPath, startGoogleChatMonitor } = + await loadGoogleChatChannelRuntime(); statusSink({ running: true, lastStartAt: Date.now(), From 59bcac472e29b818820941aece0983c590bd4208 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:05:06 +0000 Subject: [PATCH 080/943] fix: gate setup-only plugin side effects --- extensions/discord/index.ts | 3 ++ .../discord/src/monitor/provider.test.ts | 20 ++++++++- extensions/discord/src/monitor/provider.ts | 22 +++++++--- extensions/feishu/index.ts | 3 ++ extensions/line/index.ts | 3 ++ extensions/lobster/src/lobster-tool.test.ts | 1 + extensions/mattermost/index.test.ts | 43 +++++++++++++++++++ extensions/mattermost/index.ts | 3 ++ extensions/nostr/index.ts | 3 ++ extensions/test-utils/plugin-api.ts | 1 + extensions/tlon/index.ts | 3 ++ extensions/zalouser/index.ts | 3 ++ src/plugins/registry.ts | 4 +- src/plugins/types.ts | 3 ++ 14 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 extensions/mattermost/index.test.ts diff --git a/extensions/discord/index.ts b/extensions/discord/index.ts index ad441b09bc1..b08a27f80b5 100644 --- a/extensions/discord/index.ts +++ b/extensions/discord/index.ts @@ -12,6 +12,9 @@ const plugin = { register(api: OpenClawPluginApi) { setDiscordRuntime(api.runtime); api.registerChannel({ plugin: discordPlugin }); + if (api.registrationMode !== "full") { + return; + } registerDiscordSubagentHooks(api); }, }; diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index 81f8fa9f5e1..8ded5f982ae 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -46,9 +46,11 @@ const { resolveDiscordAllowlistConfigMock, resolveNativeCommandsEnabledMock, resolveNativeSkillsEnabledMock, + shouldLogVerboseMock, voiceRuntimeModuleLoadedMock, } = vi.hoisted(() => { const createdBindingManagers: Array<{ stop: ReturnType }> = []; + const shouldLogVerboseMock = vi.fn(() => false); return { clientHandleDeployRequestMock: vi.fn(async () => undefined), clientConstructorOptionsMock: vi.fn(), @@ -110,6 +112,7 @@ const { })), resolveNativeCommandsEnabledMock: vi.fn(() => true), resolveNativeSkillsEnabledMock: vi.fn(() => false), + shouldLogVerboseMock, voiceRuntimeModuleLoadedMock: vi.fn(), }; }); @@ -211,7 +214,7 @@ vi.mock("../../../../src/config/config.js", () => ({ vi.mock("../../../../src/globals.js", () => ({ danger: (v: string) => v, logVerbose: vi.fn(), - shouldLogVerbose: () => false, + shouldLogVerbose: shouldLogVerboseMock, warn: (v: string) => v, })); @@ -435,6 +438,7 @@ describe("monitorDiscordProvider", () => { }); resolveNativeCommandsEnabledMock.mockClear().mockReturnValue(true); resolveNativeSkillsEnabledMock.mockClear().mockReturnValue(false); + shouldLogVerboseMock.mockClear().mockReturnValue(false); voiceRuntimeModuleLoadedMock.mockClear(); }); @@ -842,6 +846,7 @@ describe("monitorDiscordProvider", () => { emitter.emit("debug", "WebSocket connection opened"); return { id: "bot-1", username: "Molty" }; }); + shouldLogVerboseMock.mockReturnValue(true); await monitorDiscordProvider({ config: baseConfig(), @@ -861,4 +866,17 @@ describe("monitorDiscordProvider", () => { ), ).toBe(true); }); + + it("keeps Discord startup chatter quiet by default", async () => { + const { monitorDiscordProvider } = await import("./provider.js"); + const runtime = baseRuntime(); + + await monitorDiscordProvider({ + config: baseConfig(), + runtime, + }); + + const messages = vi.mocked(runtime.log).mock.calls.map((call) => String(call[0])); + expect(messages.some((msg) => msg.includes("discord startup ["))).toBe(false); + }); }); diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index de174b9d8bf..4f8af71f0d5 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -273,14 +273,18 @@ async function deployDiscordCommands(params: { body === undefined ? undefined : Buffer.byteLength(typeof body === "string" ? body : JSON.stringify(body), "utf8"); - params.runtime.log?.( - `discord startup [${accountId}] deploy-rest:put:start ${Math.max(0, Date.now() - startupStartedAt)}ms path=${path}${typeof commandCount === "number" ? ` commands=${commandCount}` : ""}${typeof bodyBytes === "number" ? ` bytes=${bodyBytes}` : ""}`, - ); + if (shouldLogVerbose()) { + params.runtime.log?.( + `discord startup [${accountId}] deploy-rest:put:start ${Math.max(0, Date.now() - startupStartedAt)}ms path=${path}${typeof commandCount === "number" ? ` commands=${commandCount}` : ""}${typeof bodyBytes === "number" ? ` bytes=${bodyBytes}` : ""}`, + ); + } try { const result = await originalPut(path, data, query); - params.runtime.log?.( - `discord startup [${accountId}] deploy-rest:put:done ${Math.max(0, Date.now() - startupStartedAt)}ms path=${path} requestMs=${Date.now() - startedAt}`, - ); + if (shouldLogVerbose()) { + params.runtime.log?.( + `discord startup [${accountId}] deploy-rest:put:done ${Math.max(0, Date.now() - startupStartedAt)}ms path=${path} requestMs=${Date.now() - startedAt}`, + ); + } return result; } catch (err) { params.runtime.error?.( @@ -359,6 +363,9 @@ function logDiscordStartupPhase(params: { gateway?: GatewayPlugin; details?: string; }) { + if (!shouldLogVerbose()) { + return; + } const elapsedMs = Math.max(0, Date.now() - params.startAt); const suffix = [params.details, formatDiscordStartupGatewayState(params.gateway)] .filter((value): value is string => Boolean(value)) @@ -768,6 +775,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const lifecycleGateway = client.getPlugin("gateway"); earlyGatewayEmitter = getDiscordGatewayEmitter(lifecycleGateway); onEarlyGatewayDebug = (msg: unknown) => { + if (!shouldLogVerbose()) { + return; + } runtime.log?.( `discord startup [${account.accountId}] gateway-debug ${Math.max(0, Date.now() - startupStartedAt)}ms ${String(msg)}`, ); diff --git a/extensions/feishu/index.ts b/extensions/feishu/index.ts index e01a975615a..ba7ac26922b 100644 --- a/extensions/feishu/index.ts +++ b/extensions/feishu/index.ts @@ -54,6 +54,9 @@ const plugin = { register(api: OpenClawPluginApi) { setFeishuRuntime(api.runtime); api.registerChannel({ plugin: feishuPlugin }); + if (api.registrationMode !== "full") { + return; + } registerFeishuSubagentHooks(api); registerFeishuDocTools(api); registerFeishuChatTools(api); diff --git a/extensions/line/index.ts b/extensions/line/index.ts index 961baf1f01b..59b1d97920d 100644 --- a/extensions/line/index.ts +++ b/extensions/line/index.ts @@ -12,6 +12,9 @@ const plugin = { register(api: OpenClawPluginApi) { setLineRuntime(api.runtime); api.registerChannel({ plugin: linePlugin }); + if (api.registrationMode !== "full") { + return; + } registerLineCardCommand(api); }, }; diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 7c62501aa6f..bde3767845c 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -32,6 +32,7 @@ function fakeApi(overrides: Partial = {}): OpenClawPluginApi id: "lobster", name: "lobster", source: "test", + registrationMode: "full", config: {}, pluginConfig: {}, // oxlint-disable-next-line typescript/no-explicit-any diff --git a/extensions/mattermost/index.test.ts b/extensions/mattermost/index.test.ts new file mode 100644 index 00000000000..b2ef565c4d2 --- /dev/null +++ b/extensions/mattermost/index.test.ts @@ -0,0 +1,43 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/mattermost"; +import { describe, expect, it, vi } from "vitest"; +import { createTestPluginApi } from "../test-utils/plugin-api.js"; +import plugin from "./index.js"; + +function createApi( + registrationMode: OpenClawPluginApi["registrationMode"], + registerHttpRoute = vi.fn(), +): OpenClawPluginApi { + return createTestPluginApi({ + id: "mattermost", + name: "Mattermost", + source: "test", + config: {}, + runtime: {} as OpenClawPluginApi["runtime"], + registrationMode, + registerHttpRoute, + }); +} + +describe("mattermost plugin register", () => { + it("skips slash callback registration in setup-only mode", () => { + const registerHttpRoute = vi.fn(); + + plugin.register(createApi("setup-only", registerHttpRoute)); + + expect(registerHttpRoute).not.toHaveBeenCalled(); + }); + + it("registers slash callback routes in full mode", () => { + const registerHttpRoute = vi.fn(); + + plugin.register(createApi("full", registerHttpRoute)); + + expect(registerHttpRoute).toHaveBeenCalledTimes(1); + expect(registerHttpRoute).toHaveBeenCalledWith( + expect.objectContaining({ + path: "/api/channels/mattermost/command", + auth: "plugin", + }), + ); + }); +}); diff --git a/extensions/mattermost/index.ts b/extensions/mattermost/index.ts index 1dbf616c061..de6f4e1d8a0 100644 --- a/extensions/mattermost/index.ts +++ b/extensions/mattermost/index.ts @@ -12,6 +12,9 @@ const plugin = { register(api: OpenClawPluginApi) { setMattermostRuntime(api.runtime); api.registerChannel({ plugin: mattermostPlugin }); + if (api.registrationMode !== "full") { + return; + } // Register the HTTP route for slash command callbacks. // The actual command registration with MM happens in the monitor diff --git a/extensions/nostr/index.ts b/extensions/nostr/index.ts index aa8901bd2b9..d8fdb203924 100644 --- a/extensions/nostr/index.ts +++ b/extensions/nostr/index.ts @@ -14,6 +14,9 @@ const plugin = { register(api: OpenClawPluginApi) { setNostrRuntime(api.runtime); api.registerChannel({ plugin: nostrPlugin }); + if (api.registrationMode !== "full") { + return; + } // Register HTTP handler for profile management const httpHandler = createNostrProfileHttpHandler({ diff --git a/extensions/test-utils/plugin-api.ts b/extensions/test-utils/plugin-api.ts index a757344bd31..c2eaeced2e5 100644 --- a/extensions/test-utils/plugin-api.ts +++ b/extensions/test-utils/plugin-api.ts @@ -5,6 +5,7 @@ type TestPluginApiInput = Partial & export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi { return { + registrationMode: "full", logger: { info() {}, warn() {}, error() {}, debug() {} }, registerTool() {}, registerHook() {}, diff --git a/extensions/tlon/index.ts b/extensions/tlon/index.ts index 36be4651b1d..2927a9a4b53 100644 --- a/extensions/tlon/index.ts +++ b/extensions/tlon/index.ts @@ -138,6 +138,9 @@ const plugin = { register(api: OpenClawPluginApi) { setTlonRuntime(api.runtime); api.registerChannel({ plugin: tlonPlugin }); + if (api.registrationMode !== "full") { + return; + } api.logger.debug?.("[tlon] Registering tlon tool"); api.registerTool({ diff --git a/extensions/zalouser/index.ts b/extensions/zalouser/index.ts index b169292e954..747a7e26531 100644 --- a/extensions/zalouser/index.ts +++ b/extensions/zalouser/index.ts @@ -12,6 +12,9 @@ const plugin = { register(api: OpenClawPluginApi) { setZalouserRuntime(api.runtime); api.registerChannel({ plugin: zalouserPlugin, dock: zalouserDock }); + if (api.registrationMode !== "full") { + return; + } api.registerTool({ name: "zalouser", diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 56abbe79bb4..8e04106dc9c 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -43,6 +43,7 @@ import type { PluginLogger, PluginOrigin, PluginKind, + PluginRegistrationMode, PluginHookName, PluginHookHandlerMap, PluginHookRegistration as TypedPluginHookRegistration, @@ -186,8 +187,6 @@ type PluginTypedHookPolicy = { allowPromptInjection?: boolean; }; -type PluginRegistrationMode = "full" | "setup-only"; - const constrainLegacyPromptInjectionHook = ( handler: PluginHookHandlerMap["before_agent_start"], ): PluginHookHandlerMap["before_agent_start"] => { @@ -734,6 +733,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { description: record.description, source: record.source, rootDir: record.rootDir, + registrationMode, config: params.config, pluginConfig: params.pluginConfig, runtime: registryParams.runtime, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 6b26dfd8fe6..09a706a51ea 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -839,6 +839,8 @@ export type OpenClawPluginModule = | OpenClawPluginDefinition | ((api: OpenClawPluginApi) => void | Promise); +export type PluginRegistrationMode = "full" | "setup-only"; + export type OpenClawPluginApi = { id: string; name: string; @@ -846,6 +848,7 @@ export type OpenClawPluginApi = { description?: string; source: string; rootDir?: string; + registrationMode: PluginRegistrationMode; config: OpenClawConfig; pluginConfig?: Record; runtime: PluginRuntime; From e8156c8281fa6f0481ddee093222dba9dea81397 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 00:39:27 +0000 Subject: [PATCH 081/943] feat(web-search): add plugin-backed search providers --- extensions/moonshot/index.ts | 23 +- extensions/web-search-brave/index.ts | 32 + .../web-search-brave/openclaw.plugin.json | 8 + extensions/web-search-brave/package.json | 12 + extensions/web-search-gemini/index.ts | 33 + .../web-search-gemini/openclaw.plugin.json | 8 + extensions/web-search-gemini/package.json | 12 + extensions/web-search-grok/index.ts | 33 + .../web-search-grok/openclaw.plugin.json | 8 + extensions/web-search-grok/package.json | 12 + extensions/web-search-perplexity/index.ts | 33 + .../openclaw.plugin.json | 8 + extensions/web-search-perplexity/package.json | 12 + src/agents/tools/web-search-core.ts | 2235 +++++++++++++++++ src/agents/tools/web-search-plugin-factory.ts | 85 + src/agents/tools/web-search.redirect.test.ts | 44 +- src/agents/tools/web-search.ts | 2228 +--------------- src/commands/onboard-search.test.ts | 70 +- src/commands/onboard-search.ts | 99 +- src/config/config.web-search-provider.test.ts | 34 + src/plugins/loader.ts | 1 + src/plugins/registry.ts | 48 + src/plugins/types.ts | 30 + src/plugins/web-search-providers.test.ts | 137 + src/plugins/web-search-providers.ts | 110 + src/secrets/runtime-web-tools.ts | 168 +- src/secrets/runtime-web-tools.types.ts | 36 + 27 files changed, 3195 insertions(+), 2364 deletions(-) create mode 100644 extensions/web-search-brave/index.ts create mode 100644 extensions/web-search-brave/openclaw.plugin.json create mode 100644 extensions/web-search-brave/package.json create mode 100644 extensions/web-search-gemini/index.ts create mode 100644 extensions/web-search-gemini/openclaw.plugin.json create mode 100644 extensions/web-search-gemini/package.json create mode 100644 extensions/web-search-grok/index.ts create mode 100644 extensions/web-search-grok/openclaw.plugin.json create mode 100644 extensions/web-search-grok/package.json create mode 100644 extensions/web-search-perplexity/index.ts create mode 100644 extensions/web-search-perplexity/openclaw.plugin.json create mode 100644 extensions/web-search-perplexity/package.json create mode 100644 src/agents/tools/web-search-core.ts create mode 100644 src/agents/tools/web-search-plugin-factory.ts create mode 100644 src/plugins/web-search-providers.test.ts create mode 100644 src/plugins/web-search-providers.ts create mode 100644 src/secrets/runtime-web-tools.types.ts diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts index 59176e42c15..44f77d7b56b 100644 --- a/extensions/moonshot/index.ts +++ b/extensions/moonshot/index.ts @@ -1,9 +1,15 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { buildMoonshotProvider } from "../../src/agents/models-config.providers.static.js"; import { createMoonshotThinkingWrapper, resolveMoonshotThinkingType, } from "../../src/agents/pi-embedded-runner/moonshot-stream-wrappers.js"; +import { + createPluginBackedWebSearchProvider, + getScopedCredentialValue, + setScopedCredentialValue, +} from "../../src/agents/tools/web-search-plugin-factory.js"; +import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; +import type { OpenClawPluginApi } from "../../src/plugins/types.js"; const PROVIDER_ID = "moonshot"; @@ -46,6 +52,21 @@ const moonshotPlugin = { return createMoonshotThinkingWrapper(ctx.streamFn, thinkingType); }, }); + api.registerWebSearchProvider( + createPluginBackedWebSearchProvider({ + id: "kimi", + label: "Kimi (Moonshot)", + hint: "Moonshot web search", + envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"], + placeholder: "sk-...", + signupUrl: "https://platform.moonshot.cn/", + docsUrl: "https://docs.openclaw.ai/tools/web", + autoDetectOrder: 40, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "kimi"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "kimi", value), + }), + ); }, }; diff --git a/extensions/web-search-brave/index.ts b/extensions/web-search-brave/index.ts new file mode 100644 index 00000000000..7345e10f011 --- /dev/null +++ b/extensions/web-search-brave/index.ts @@ -0,0 +1,32 @@ +import { + createPluginBackedWebSearchProvider, + getTopLevelCredentialValue, + setTopLevelCredentialValue, +} from "../../src/agents/tools/web-search-plugin-factory.js"; +import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; +import type { OpenClawPluginApi } from "../../src/plugins/types.js"; + +const braveSearchPlugin = { + id: "web-search-brave", + name: "Web Search Brave Provider", + description: "Bundled Brave provider for the web_search tool", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerWebSearchProvider( + createPluginBackedWebSearchProvider({ + id: "brave", + label: "Brave Search", + hint: "Structured results · country/language/time filters", + envVars: ["BRAVE_API_KEY"], + placeholder: "BSA...", + signupUrl: "https://brave.com/search/api/", + docsUrl: "https://docs.openclaw.ai/brave-search", + autoDetectOrder: 10, + getCredentialValue: getTopLevelCredentialValue, + setCredentialValue: setTopLevelCredentialValue, + }), + ); + }, +}; + +export default braveSearchPlugin; diff --git a/extensions/web-search-brave/openclaw.plugin.json b/extensions/web-search-brave/openclaw.plugin.json new file mode 100644 index 00000000000..606091921e9 --- /dev/null +++ b/extensions/web-search-brave/openclaw.plugin.json @@ -0,0 +1,8 @@ +{ + "id": "web-search-brave", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/web-search-brave/package.json b/extensions/web-search-brave/package.json new file mode 100644 index 00000000000..c8807445a28 --- /dev/null +++ b/extensions/web-search-brave/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/web-search-brave", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Brave web search provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/web-search-gemini/index.ts b/extensions/web-search-gemini/index.ts new file mode 100644 index 00000000000..998fbd69a04 --- /dev/null +++ b/extensions/web-search-gemini/index.ts @@ -0,0 +1,33 @@ +import { + createPluginBackedWebSearchProvider, + getScopedCredentialValue, + setScopedCredentialValue, +} from "../../src/agents/tools/web-search-plugin-factory.js"; +import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; +import type { OpenClawPluginApi } from "../../src/plugins/types.js"; + +const geminiSearchPlugin = { + id: "web-search-gemini", + name: "Web Search Gemini Provider", + description: "Bundled Gemini provider for the web_search tool", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerWebSearchProvider( + createPluginBackedWebSearchProvider({ + id: "gemini", + label: "Gemini (Google Search)", + hint: "Google Search grounding · AI-synthesized", + envVars: ["GEMINI_API_KEY"], + placeholder: "AIza...", + signupUrl: "https://aistudio.google.com/apikey", + docsUrl: "https://docs.openclaw.ai/tools/web", + autoDetectOrder: 20, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "gemini"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "gemini", value), + }), + ); + }, +}; + +export default geminiSearchPlugin; diff --git a/extensions/web-search-gemini/openclaw.plugin.json b/extensions/web-search-gemini/openclaw.plugin.json new file mode 100644 index 00000000000..a2baa4b274d --- /dev/null +++ b/extensions/web-search-gemini/openclaw.plugin.json @@ -0,0 +1,8 @@ +{ + "id": "web-search-gemini", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/web-search-gemini/package.json b/extensions/web-search-gemini/package.json new file mode 100644 index 00000000000..1a595b2b060 --- /dev/null +++ b/extensions/web-search-gemini/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/web-search-gemini", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Gemini web search provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/web-search-grok/index.ts b/extensions/web-search-grok/index.ts new file mode 100644 index 00000000000..726879ed43b --- /dev/null +++ b/extensions/web-search-grok/index.ts @@ -0,0 +1,33 @@ +import { + createPluginBackedWebSearchProvider, + getScopedCredentialValue, + setScopedCredentialValue, +} from "../../src/agents/tools/web-search-plugin-factory.js"; +import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; +import type { OpenClawPluginApi } from "../../src/plugins/types.js"; + +const grokSearchPlugin = { + id: "web-search-grok", + name: "Web Search Grok Provider", + description: "Bundled Grok provider for the web_search tool", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerWebSearchProvider( + createPluginBackedWebSearchProvider({ + id: "grok", + label: "Grok (xAI)", + hint: "xAI web-grounded responses", + envVars: ["XAI_API_KEY"], + placeholder: "xai-...", + signupUrl: "https://console.x.ai/", + docsUrl: "https://docs.openclaw.ai/tools/web", + autoDetectOrder: 30, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "grok"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "grok", value), + }), + ); + }, +}; + +export default grokSearchPlugin; diff --git a/extensions/web-search-grok/openclaw.plugin.json b/extensions/web-search-grok/openclaw.plugin.json new file mode 100644 index 00000000000..ccc55644521 --- /dev/null +++ b/extensions/web-search-grok/openclaw.plugin.json @@ -0,0 +1,8 @@ +{ + "id": "web-search-grok", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/web-search-grok/package.json b/extensions/web-search-grok/package.json new file mode 100644 index 00000000000..9baa872250e --- /dev/null +++ b/extensions/web-search-grok/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/web-search-grok", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Grok web search provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/web-search-perplexity/index.ts b/extensions/web-search-perplexity/index.ts new file mode 100644 index 00000000000..83f778aba96 --- /dev/null +++ b/extensions/web-search-perplexity/index.ts @@ -0,0 +1,33 @@ +import { + createPluginBackedWebSearchProvider, + getScopedCredentialValue, + setScopedCredentialValue, +} from "../../src/agents/tools/web-search-plugin-factory.js"; +import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; +import type { OpenClawPluginApi } from "../../src/plugins/types.js"; + +const perplexitySearchPlugin = { + id: "web-search-perplexity", + name: "Web Search Perplexity Provider", + description: "Bundled Perplexity provider for the web_search tool", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerWebSearchProvider( + createPluginBackedWebSearchProvider({ + id: "perplexity", + label: "Perplexity Search", + hint: "Structured results · domain/country/language/time filters", + envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"], + placeholder: "pplx-...", + signupUrl: "https://www.perplexity.ai/settings/api", + docsUrl: "https://docs.openclaw.ai/perplexity", + autoDetectOrder: 50, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "perplexity"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "perplexity", value), + }), + ); + }, +}; + +export default perplexitySearchPlugin; diff --git a/extensions/web-search-perplexity/openclaw.plugin.json b/extensions/web-search-perplexity/openclaw.plugin.json new file mode 100644 index 00000000000..fc9907a3dc2 --- /dev/null +++ b/extensions/web-search-perplexity/openclaw.plugin.json @@ -0,0 +1,8 @@ +{ + "id": "web-search-perplexity", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/web-search-perplexity/package.json b/extensions/web-search-perplexity/package.json new file mode 100644 index 00000000000..d3724a3b2e3 --- /dev/null +++ b/extensions/web-search-perplexity/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/web-search-perplexity", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Perplexity web search provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/src/agents/tools/web-search-core.ts b/src/agents/tools/web-search-core.ts new file mode 100644 index 00000000000..48d2d620b49 --- /dev/null +++ b/src/agents/tools/web-search-core.ts @@ -0,0 +1,2235 @@ +import { Type } from "@sinclair/typebox"; +import { formatCliCommand } from "../../cli/command-format.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js"; +import { logVerbose } from "../../globals.js"; +import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.types.js"; +import { wrapWebContent } from "../../security/external-content.js"; +import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; +import type { AnyAgentTool } from "./common.js"; +import { jsonResult, readNumberParam, readStringArrayParam, readStringParam } from "./common.js"; +import { withTrustedWebToolsEndpoint } from "./web-guarded-fetch.js"; +import { resolveCitationRedirectUrl } from "./web-search-citation-redirect.js"; +import { + CacheEntry, + DEFAULT_CACHE_TTL_MINUTES, + DEFAULT_TIMEOUT_SECONDS, + normalizeCacheKey, + readCache, + readResponseText, + resolveCacheTtlMs, + resolveTimeoutSeconds, + writeCache, +} from "./web-shared.js"; + +const SEARCH_PROVIDERS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const; +const DEFAULT_SEARCH_COUNT = 5; +const MAX_SEARCH_COUNT = 10; + +const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"; +const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context"; +const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1"; +const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai"; +const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search"; +const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro"; +const PERPLEXITY_KEY_PREFIXES = ["pplx-"]; +const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; + +const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses"; +const DEFAULT_GROK_MODEL = "grok-4-1-fast"; +const DEFAULT_KIMI_BASE_URL = "https://api.moonshot.ai/v1"; +const DEFAULT_KIMI_MODEL = "moonshot-v1-128k"; +const KIMI_WEB_SEARCH_TOOL = { + type: "builtin_function", + function: { name: "$web_search" }, +} as const; + +const SEARCH_CACHE_KEY = Symbol.for("openclaw.web-search.cache"); + +function getSharedSearchCache(): Map>> { + const root = globalThis as Record; + const existing = root[SEARCH_CACHE_KEY]; + if (existing instanceof Map) { + return existing as Map>>; + } + const next = new Map>>(); + root[SEARCH_CACHE_KEY] = next; + return next; +} + +const SEARCH_CACHE = getSharedSearchCache(); +const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]); +const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/; +const BRAVE_SEARCH_LANG_CODES = new Set([ + "ar", + "eu", + "bn", + "bg", + "ca", + "zh-hans", + "zh-hant", + "hr", + "cs", + "da", + "nl", + "en", + "en-gb", + "et", + "fi", + "fr", + "gl", + "de", + "el", + "gu", + "he", + "hi", + "hu", + "is", + "it", + "jp", + "kn", + "ko", + "lv", + "lt", + "ms", + "ml", + "mr", + "nb", + "pl", + "pt-br", + "pt-pt", + "pa", + "ro", + "ru", + "sr", + "sk", + "sl", + "es", + "sv", + "ta", + "te", + "th", + "tr", + "uk", + "vi", +]); +const BRAVE_SEARCH_LANG_ALIASES: Record = { + ja: "jp", + zh: "zh-hans", + "zh-cn": "zh-hans", + "zh-hk": "zh-hant", + "zh-sg": "zh-hans", + "zh-tw": "zh-hant", +}; +const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i; +const PERPLEXITY_RECENCY_VALUES = new Set(["day", "week", "month", "year"]); + +const FRESHNESS_TO_RECENCY: Record = { + pd: "day", + pw: "week", + pm: "month", + py: "year", +}; +const RECENCY_TO_FRESHNESS: Record = { + day: "pd", + week: "pw", + month: "pm", + year: "py", +}; + +const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; +const PERPLEXITY_DATE_PATTERN = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/; + +function isoToPerplexityDate(iso: string): string | undefined { + const match = iso.match(ISO_DATE_PATTERN); + if (!match) { + return undefined; + } + const [, year, month, day] = match; + return `${parseInt(month, 10)}/${parseInt(day, 10)}/${year}`; +} + +function normalizeToIsoDate(value: string): string | undefined { + const trimmed = value.trim(); + if (ISO_DATE_PATTERN.test(trimmed)) { + return isValidIsoDate(trimmed) ? trimmed : undefined; + } + const match = trimmed.match(PERPLEXITY_DATE_PATTERN); + if (match) { + const [, month, day, year] = match; + const iso = `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`; + return isValidIsoDate(iso) ? iso : undefined; + } + return undefined; +} + +function createWebSearchSchema(params: { + provider: (typeof SEARCH_PROVIDERS)[number]; + perplexityTransport?: PerplexityTransport; +}) { + const querySchema = { + query: Type.String({ description: "Search query string." }), + count: Type.Optional( + Type.Number({ + description: "Number of results to return (1-10).", + minimum: 1, + maximum: MAX_SEARCH_COUNT, + }), + ), + } as const; + + const filterSchema = { + country: Type.Optional( + Type.String({ + description: + "2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.", + }), + ), + language: Type.Optional( + Type.String({ + description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').", + }), + ), + freshness: Type.Optional( + Type.String({ + description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.", + }), + ), + date_after: Type.Optional( + Type.String({ + description: "Only results published after this date (YYYY-MM-DD).", + }), + ), + date_before: Type.Optional( + Type.String({ + description: "Only results published before this date (YYYY-MM-DD).", + }), + ), + } as const; + + const perplexityStructuredFilterSchema = { + country: Type.Optional( + Type.String({ + description: + "Native Perplexity Search API only. 2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.", + }), + ), + language: Type.Optional( + Type.String({ + description: + "Native Perplexity Search API only. ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').", + }), + ), + date_after: Type.Optional( + Type.String({ + description: + "Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).", + }), + ), + date_before: Type.Optional( + Type.String({ + description: + "Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).", + }), + ), + } as const; + + if (params.provider === "brave") { + return Type.Object({ + ...querySchema, + ...filterSchema, + search_lang: Type.Optional( + Type.String({ + description: + "Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').", + }), + ), + ui_lang: Type.Optional( + Type.String({ + description: + "Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.", + }), + ), + }); + } + + if (params.provider === "perplexity") { + if (params.perplexityTransport === "chat_completions") { + return Type.Object({ + ...querySchema, + freshness: filterSchema.freshness, + }); + } + return Type.Object({ + ...querySchema, + freshness: filterSchema.freshness, + ...perplexityStructuredFilterSchema, + domain_filter: Type.Optional( + Type.Array(Type.String(), { + description: + "Native Perplexity Search API only. Domain filter (max 20). Allowlist: ['nature.com'] or denylist: ['-reddit.com']. Cannot mix.", + }), + ), + max_tokens: Type.Optional( + Type.Number({ + description: + "Native Perplexity Search API only. Total content budget across all results (default: 25000, max: 1000000).", + minimum: 1, + maximum: 1000000, + }), + ), + max_tokens_per_page: Type.Optional( + Type.Number({ + description: + "Native Perplexity Search API only. Max tokens extracted per page (default: 2048).", + minimum: 1, + }), + ), + }); + } + + // grok, gemini, kimi, etc. + return Type.Object({ + ...querySchema, + ...filterSchema, + }); +} + +type WebSearchConfig = NonNullable["web"] extends infer Web + ? Web extends { search?: infer Search } + ? Search + : undefined + : undefined; + +type BraveSearchResult = { + title?: string; + url?: string; + description?: string; + age?: string; +}; + +type BraveSearchResponse = { + web?: { + results?: BraveSearchResult[]; + }; +}; + +type BraveLlmContextResult = { url: string; title: string; snippets: string[] }; +type BraveLlmContextResponse = { + grounding: { generic?: BraveLlmContextResult[] }; + sources?: { url?: string; hostname?: string; date?: string }[]; +}; + +type BraveConfig = { + mode?: string; +}; + +type PerplexityConfig = { + apiKey?: string; + baseUrl?: string; + model?: string; +}; + +type PerplexityApiKeySource = "config" | "perplexity_env" | "openrouter_env" | "none"; +type PerplexityTransport = "search_api" | "chat_completions"; +type PerplexityBaseUrlHint = "direct" | "openrouter"; + +type GrokConfig = { + apiKey?: string; + model?: string; + inlineCitations?: boolean; +}; + +type KimiConfig = { + apiKey?: string; + baseUrl?: string; + model?: string; +}; + +type GrokSearchResponse = { + output?: Array<{ + type?: string; + role?: string; + text?: string; // present when type === "output_text" (top-level output_text block) + content?: Array<{ + type?: string; + text?: string; + annotations?: Array<{ + type?: string; + url?: string; + start_index?: number; + end_index?: number; + }>; + }>; + annotations?: Array<{ + type?: string; + url?: string; + start_index?: number; + end_index?: number; + }>; + }>; + output_text?: string; // deprecated field - kept for backwards compatibility + citations?: string[]; + inline_citations?: Array<{ + start_index: number; + end_index: number; + url: string; + }>; +}; + +type KimiToolCall = { + id?: string; + type?: string; + function?: { + name?: string; + arguments?: string; + }; +}; + +type KimiMessage = { + role?: string; + content?: string; + reasoning_content?: string; + tool_calls?: KimiToolCall[]; +}; + +type KimiSearchResponse = { + choices?: Array<{ + finish_reason?: string; + message?: KimiMessage; + }>; + search_results?: Array<{ + title?: string; + url?: string; + content?: string; + }>; +}; + +type PerplexitySearchResponse = { + choices?: Array<{ + message?: { + content?: string; + annotations?: Array<{ + type?: string; + url?: string; + url_citation?: { + url?: string; + title?: string; + start_index?: number; + end_index?: number; + }; + }>; + }; + }>; + citations?: string[]; +}; + +type PerplexitySearchApiResult = { + title?: string; + url?: string; + snippet?: string; + date?: string; + last_updated?: string; +}; + +type PerplexitySearchApiResponse = { + results?: PerplexitySearchApiResult[]; + id?: string; +}; + +function extractPerplexityCitations(data: PerplexitySearchResponse): string[] { + const normalizeUrl = (value: unknown): string | undefined => { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; + }; + + const topLevel = (data.citations ?? []) + .map(normalizeUrl) + .filter((url): url is string => Boolean(url)); + if (topLevel.length > 0) { + return [...new Set(topLevel)]; + } + + const citations: string[] = []; + for (const choice of data.choices ?? []) { + for (const annotation of choice.message?.annotations ?? []) { + if (annotation.type !== "url_citation") { + continue; + } + const url = normalizeUrl(annotation.url_citation?.url ?? annotation.url); + if (url) { + citations.push(url); + } + } + } + + return [...new Set(citations)]; +} + +function extractGrokContent(data: GrokSearchResponse): { + text: string | undefined; + annotationCitations: string[]; +} { + // xAI Responses API format: find the message output with text content + for (const output of data.output ?? []) { + if (output.type === "message") { + for (const block of output.content ?? []) { + if (block.type === "output_text" && typeof block.text === "string" && block.text) { + const urls = (block.annotations ?? []) + .filter((a) => a.type === "url_citation" && typeof a.url === "string") + .map((a) => a.url as string); + return { text: block.text, annotationCitations: [...new Set(urls)] }; + } + } + } + // Some xAI responses place output_text blocks directly in the output array + // without a message wrapper. + if ( + output.type === "output_text" && + "text" in output && + typeof output.text === "string" && + output.text + ) { + const rawAnnotations = + "annotations" in output && Array.isArray(output.annotations) ? output.annotations : []; + const urls = rawAnnotations + .filter( + (a: Record) => a.type === "url_citation" && typeof a.url === "string", + ) + .map((a: Record) => a.url as string); + return { text: output.text, annotationCitations: [...new Set(urls)] }; + } + } + // Fallback: deprecated output_text field + const text = typeof data.output_text === "string" ? data.output_text : undefined; + return { text, annotationCitations: [] }; +} + +type GeminiConfig = { + apiKey?: string; + model?: string; +}; + +type GeminiGroundingResponse = { + candidates?: Array<{ + content?: { + parts?: Array<{ + text?: string; + }>; + }; + groundingMetadata?: { + groundingChunks?: Array<{ + web?: { + uri?: string; + title?: string; + }; + }>; + searchEntryPoint?: { + renderedContent?: string; + }; + webSearchQueries?: string[]; + }; + }>; + error?: { + code?: number; + message?: string; + status?: string; + }; +}; + +const DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"; +const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta"; + +function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig { + const search = cfg?.tools?.web?.search; + if (!search || typeof search !== "object") { + return undefined; + } + return search as WebSearchConfig; +} + +function resolveSearchEnabled(params: { search?: WebSearchConfig; sandboxed?: boolean }): boolean { + if (typeof params.search?.enabled === "boolean") { + return params.search.enabled; + } + if (params.sandboxed) { + return true; + } + return true; +} + +function resolveSearchApiKey(search?: WebSearchConfig): string | undefined { + const fromConfigRaw = + search && "apiKey" in search + ? normalizeResolvedSecretInputString({ + value: search.apiKey, + path: "tools.web.search.apiKey", + }) + : undefined; + const fromConfig = normalizeSecretInput(fromConfigRaw); + const fromEnv = normalizeSecretInput(process.env.BRAVE_API_KEY); + return fromConfig || fromEnv || undefined; +} + +function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) { + if (provider === "brave") { + return { + error: "missing_brave_api_key", + message: `web_search (brave) needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`, + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (provider === "gemini") { + return { + error: "missing_gemini_api_key", + message: + "web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (provider === "grok") { + return { + error: "missing_xai_api_key", + message: + "web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure tools.web.search.grok.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (provider === "kimi") { + return { + error: "missing_kimi_api_key", + message: + "web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure tools.web.search.kimi.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + return { + error: "missing_perplexity_api_key", + message: + "web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; +} + +function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDERS)[number] { + const raw = + search && "provider" in search && typeof search.provider === "string" + ? search.provider.trim().toLowerCase() + : ""; + if (raw === "brave") { + return "brave"; + } + if (raw === "gemini") { + return "gemini"; + } + if (raw === "grok") { + return "grok"; + } + if (raw === "kimi") { + return "kimi"; + } + if (raw === "perplexity") { + return "perplexity"; + } + + // Auto-detect provider from available API keys (alphabetical order) + if (raw === "") { + // Brave + if (resolveSearchApiKey(search)) { + logVerbose( + 'web_search: no provider configured, auto-detected "brave" from available API keys', + ); + return "brave"; + } + // Gemini + const geminiConfig = resolveGeminiConfig(search); + if (resolveGeminiApiKey(geminiConfig)) { + logVerbose( + 'web_search: no provider configured, auto-detected "gemini" from available API keys', + ); + return "gemini"; + } + // Grok + const grokConfig = resolveGrokConfig(search); + if (resolveGrokApiKey(grokConfig)) { + logVerbose( + 'web_search: no provider configured, auto-detected "grok" from available API keys', + ); + return "grok"; + } + // Kimi + const kimiConfig = resolveKimiConfig(search); + if (resolveKimiApiKey(kimiConfig)) { + logVerbose( + 'web_search: no provider configured, auto-detected "kimi" from available API keys', + ); + return "kimi"; + } + // Perplexity + const perplexityConfig = resolvePerplexityConfig(search); + const { apiKey: perplexityKey } = resolvePerplexityApiKey(perplexityConfig); + if (perplexityKey) { + logVerbose( + 'web_search: no provider configured, auto-detected "perplexity" from available API keys', + ); + return "perplexity"; + } + } + + return "brave"; +} + +function resolveBraveConfig(search?: WebSearchConfig): BraveConfig { + if (!search || typeof search !== "object") { + return {}; + } + const brave = "brave" in search ? search.brave : undefined; + if (!brave || typeof brave !== "object") { + return {}; + } + return brave as BraveConfig; +} + +function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" { + return brave.mode === "llm-context" ? "llm-context" : "web"; +} + +function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig { + if (!search || typeof search !== "object") { + return {}; + } + const perplexity = "perplexity" in search ? search.perplexity : undefined; + if (!perplexity || typeof perplexity !== "object") { + return {}; + } + return perplexity as PerplexityConfig; +} + +function resolvePerplexityApiKey(perplexity?: PerplexityConfig): { + apiKey?: string; + source: PerplexityApiKeySource; +} { + const fromConfig = normalizeApiKey(perplexity?.apiKey); + if (fromConfig) { + return { apiKey: fromConfig, source: "config" }; + } + + const fromEnvPerplexity = normalizeApiKey(process.env.PERPLEXITY_API_KEY); + if (fromEnvPerplexity) { + return { apiKey: fromEnvPerplexity, source: "perplexity_env" }; + } + + const fromEnvOpenRouter = normalizeApiKey(process.env.OPENROUTER_API_KEY); + if (fromEnvOpenRouter) { + return { apiKey: fromEnvOpenRouter, source: "openrouter_env" }; + } + + return { apiKey: undefined, source: "none" }; +} + +function normalizeApiKey(key: unknown): string { + return normalizeSecretInput(key); +} + +function inferPerplexityBaseUrlFromApiKey(apiKey?: string): PerplexityBaseUrlHint | undefined { + if (!apiKey) { + return undefined; + } + const normalized = apiKey.toLowerCase(); + if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return "direct"; + } + if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return "openrouter"; + } + return undefined; +} + +function resolvePerplexityBaseUrl( + perplexity?: PerplexityConfig, + authSource: PerplexityApiKeySource = "none", // pragma: allowlist secret + configuredKey?: string, +): string { + const fromConfig = + perplexity && "baseUrl" in perplexity && typeof perplexity.baseUrl === "string" + ? perplexity.baseUrl.trim() + : ""; + if (fromConfig) { + return fromConfig; + } + if (authSource === "perplexity_env") { + return PERPLEXITY_DIRECT_BASE_URL; + } + if (authSource === "openrouter_env") { + return DEFAULT_PERPLEXITY_BASE_URL; + } + if (authSource === "config") { + const inferred = inferPerplexityBaseUrlFromApiKey(configuredKey); + if (inferred === "openrouter") { + return DEFAULT_PERPLEXITY_BASE_URL; + } + return PERPLEXITY_DIRECT_BASE_URL; + } + return DEFAULT_PERPLEXITY_BASE_URL; +} + +function resolvePerplexityModel(perplexity?: PerplexityConfig): string { + const fromConfig = + perplexity && "model" in perplexity && typeof perplexity.model === "string" + ? perplexity.model.trim() + : ""; + return fromConfig || DEFAULT_PERPLEXITY_MODEL; +} + +function isDirectPerplexityBaseUrl(baseUrl: string): boolean { + const trimmed = baseUrl.trim(); + if (!trimmed) { + return false; + } + try { + return new URL(trimmed).hostname.toLowerCase() === "api.perplexity.ai"; + } catch { + return false; + } +} + +function resolvePerplexityRequestModel(baseUrl: string, model: string): string { + if (!isDirectPerplexityBaseUrl(baseUrl)) { + return model; + } + return model.startsWith("perplexity/") ? model.slice("perplexity/".length) : model; +} + +function resolvePerplexityTransport(perplexity?: PerplexityConfig): { + apiKey?: string; + source: PerplexityApiKeySource; + baseUrl: string; + model: string; + transport: PerplexityTransport; +} { + const auth = resolvePerplexityApiKey(perplexity); + const baseUrl = resolvePerplexityBaseUrl(perplexity, auth.source, auth.apiKey); + const model = resolvePerplexityModel(perplexity); + const hasLegacyOverride = Boolean( + (perplexity?.baseUrl && perplexity.baseUrl.trim()) || + (perplexity?.model && perplexity.model.trim()), + ); + return { + ...auth, + baseUrl, + model, + transport: + hasLegacyOverride || !isDirectPerplexityBaseUrl(baseUrl) ? "chat_completions" : "search_api", + }; +} + +function resolvePerplexitySchemaTransportHint( + perplexity?: PerplexityConfig, +): PerplexityTransport | undefined { + const hasLegacyOverride = Boolean( + (perplexity?.baseUrl && perplexity.baseUrl.trim()) || + (perplexity?.model && perplexity.model.trim()), + ); + return hasLegacyOverride ? "chat_completions" : undefined; +} + +function resolveGrokConfig(search?: WebSearchConfig): GrokConfig { + if (!search || typeof search !== "object") { + return {}; + } + const grok = "grok" in search ? search.grok : undefined; + if (!grok || typeof grok !== "object") { + return {}; + } + return grok as GrokConfig; +} + +function resolveGrokApiKey(grok?: GrokConfig): string | undefined { + const fromConfig = normalizeApiKey(grok?.apiKey); + if (fromConfig) { + return fromConfig; + } + const fromEnv = normalizeApiKey(process.env.XAI_API_KEY); + return fromEnv || undefined; +} + +function resolveGrokModel(grok?: GrokConfig): string { + const fromConfig = + grok && "model" in grok && typeof grok.model === "string" ? grok.model.trim() : ""; + return fromConfig || DEFAULT_GROK_MODEL; +} + +function resolveGrokInlineCitations(grok?: GrokConfig): boolean { + return grok?.inlineCitations === true; +} + +function resolveKimiConfig(search?: WebSearchConfig): KimiConfig { + if (!search || typeof search !== "object") { + return {}; + } + const kimi = "kimi" in search ? search.kimi : undefined; + if (!kimi || typeof kimi !== "object") { + return {}; + } + return kimi as KimiConfig; +} + +function resolveKimiApiKey(kimi?: KimiConfig): string | undefined { + const fromConfig = normalizeApiKey(kimi?.apiKey); + if (fromConfig) { + return fromConfig; + } + const fromEnvKimi = normalizeApiKey(process.env.KIMI_API_KEY); + if (fromEnvKimi) { + return fromEnvKimi; + } + const fromEnvMoonshot = normalizeApiKey(process.env.MOONSHOT_API_KEY); + return fromEnvMoonshot || undefined; +} + +function resolveKimiModel(kimi?: KimiConfig): string { + const fromConfig = + kimi && "model" in kimi && typeof kimi.model === "string" ? kimi.model.trim() : ""; + return fromConfig || DEFAULT_KIMI_MODEL; +} + +function resolveKimiBaseUrl(kimi?: KimiConfig): string { + const fromConfig = + kimi && "baseUrl" in kimi && typeof kimi.baseUrl === "string" ? kimi.baseUrl.trim() : ""; + return fromConfig || DEFAULT_KIMI_BASE_URL; +} + +function resolveGeminiConfig(search?: WebSearchConfig): GeminiConfig { + if (!search || typeof search !== "object") { + return {}; + } + const gemini = "gemini" in search ? search.gemini : undefined; + if (!gemini || typeof gemini !== "object") { + return {}; + } + return gemini as GeminiConfig; +} + +function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined { + const fromConfig = normalizeApiKey(gemini?.apiKey); + if (fromConfig) { + return fromConfig; + } + const fromEnv = normalizeApiKey(process.env.GEMINI_API_KEY); + return fromEnv || undefined; +} + +function resolveGeminiModel(gemini?: GeminiConfig): string { + const fromConfig = + gemini && "model" in gemini && typeof gemini.model === "string" ? gemini.model.trim() : ""; + return fromConfig || DEFAULT_GEMINI_MODEL; +} + +async function withTrustedWebSearchEndpoint( + params: { + url: string; + timeoutSeconds: number; + init: RequestInit; + }, + run: (response: Response) => Promise, +): Promise { + return withTrustedWebToolsEndpoint( + { + url: params.url, + init: params.init, + timeoutSeconds: params.timeoutSeconds, + }, + async ({ response }) => run(response), + ); +} + +async function runGeminiSearch(params: { + query: string; + apiKey: string; + model: string; + timeoutSeconds: number; +}): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> { + const endpoint = `${GEMINI_API_BASE}/models/${params.model}:generateContent`; + + return withTrustedWebSearchEndpoint( + { + url: endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-goog-api-key": params.apiKey, + }, + body: JSON.stringify({ + contents: [ + { + parts: [{ text: params.query }], + }, + ], + tools: [{ google_search: {} }], + }), + }, + }, + async (res) => { + if (!res.ok) { + const detailResult = await readResponseText(res, { maxBytes: 64_000 }); + // Strip API key from any error detail to prevent accidental key leakage in logs + const safeDetail = (detailResult.text || res.statusText).replace( + /key=[^&\s]+/gi, + "key=***", + ); + throw new Error(`Gemini API error (${res.status}): ${safeDetail}`); + } + + let data: GeminiGroundingResponse; + try { + data = (await res.json()) as GeminiGroundingResponse; + } catch (err) { + const safeError = String(err).replace(/key=[^&\s]+/gi, "key=***"); + throw new Error(`Gemini API returned invalid JSON: ${safeError}`, { cause: err }); + } + + if (data.error) { + const rawMsg = data.error.message || data.error.status || "unknown"; + const safeMsg = rawMsg.replace(/key=[^&\s]+/gi, "key=***"); + throw new Error(`Gemini API error (${data.error.code}): ${safeMsg}`); + } + + const candidate = data.candidates?.[0]; + const content = + candidate?.content?.parts + ?.map((p) => p.text) + .filter(Boolean) + .join("\n") ?? "No response"; + + const groundingChunks = candidate?.groundingMetadata?.groundingChunks ?? []; + const rawCitations = groundingChunks + .filter((chunk) => chunk.web?.uri) + .map((chunk) => ({ + url: chunk.web!.uri!, + title: chunk.web?.title || undefined, + })); + + // Resolve Google grounding redirect URLs to direct URLs with concurrency cap. + // Gemini typically returns 3-8 citations; cap at 10 concurrent to be safe. + const MAX_CONCURRENT_REDIRECTS = 10; + const citations: Array<{ url: string; title?: string }> = []; + for (let i = 0; i < rawCitations.length; i += MAX_CONCURRENT_REDIRECTS) { + const batch = rawCitations.slice(i, i + MAX_CONCURRENT_REDIRECTS); + const resolved = await Promise.all( + batch.map(async (citation) => { + const resolvedUrl = await resolveCitationRedirectUrl(citation.url); + return { ...citation, url: resolvedUrl }; + }), + ); + citations.push(...resolved); + } + + return { content, citations }; + }, + ); +} + +function resolveSearchCount(value: unknown, fallback: number): number { + const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback; + const clamped = Math.max(1, Math.min(MAX_SEARCH_COUNT, Math.floor(parsed))); + return clamped; +} + +function normalizeBraveSearchLang(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + const canonical = BRAVE_SEARCH_LANG_ALIASES[trimmed.toLowerCase()] ?? trimmed.toLowerCase(); + if (!BRAVE_SEARCH_LANG_CODES.has(canonical)) { + return undefined; + } + return canonical; +} + +function normalizeBraveUiLang(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + const match = trimmed.match(BRAVE_UI_LANG_LOCALE); + if (!match) { + return undefined; + } + const [, language, region] = match; + return `${language.toLowerCase()}-${region.toUpperCase()}`; +} + +function normalizeBraveLanguageParams(params: { search_lang?: string; ui_lang?: string }): { + search_lang?: string; + ui_lang?: string; + invalidField?: "search_lang" | "ui_lang"; +} { + const rawSearchLang = params.search_lang?.trim() || undefined; + const rawUiLang = params.ui_lang?.trim() || undefined; + let searchLangCandidate = rawSearchLang; + let uiLangCandidate = rawUiLang; + + // Recover common LLM mix-up: locale in search_lang + short code in ui_lang. + if (normalizeBraveUiLang(rawSearchLang) && normalizeBraveSearchLang(rawUiLang)) { + searchLangCandidate = rawUiLang; + uiLangCandidate = rawSearchLang; + } + + const search_lang = normalizeBraveSearchLang(searchLangCandidate); + if (searchLangCandidate && !search_lang) { + return { invalidField: "search_lang" }; + } + + const ui_lang = normalizeBraveUiLang(uiLangCandidate); + if (uiLangCandidate && !ui_lang) { + return { invalidField: "ui_lang" }; + } + + return { search_lang, ui_lang }; +} + +/** + * Normalizes freshness shortcut to the provider's expected format. + * Accepts both Brave format (pd/pw/pm/py) and Perplexity format (day/week/month/year). + * For Brave, also accepts date ranges (YYYY-MM-DDtoYYYY-MM-DD). + */ +function normalizeFreshness( + value: string | undefined, + provider: (typeof SEARCH_PROVIDERS)[number], +): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + const lower = trimmed.toLowerCase(); + + if (BRAVE_FRESHNESS_SHORTCUTS.has(lower)) { + return provider === "brave" ? lower : FRESHNESS_TO_RECENCY[lower]; + } + + if (PERPLEXITY_RECENCY_VALUES.has(lower)) { + return provider === "perplexity" ? lower : RECENCY_TO_FRESHNESS[lower]; + } + + // Brave date range support + if (provider === "brave") { + const match = trimmed.match(BRAVE_FRESHNESS_RANGE); + if (match) { + const [, start, end] = match; + if (isValidIsoDate(start) && isValidIsoDate(end) && start <= end) { + return `${start}to${end}`; + } + } + } + + return undefined; +} + +function isValidIsoDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return false; + } + const [year, month, day] = value.split("-").map((part) => Number.parseInt(part, 10)); + if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { + return false; + } + + const date = new Date(Date.UTC(year, month - 1, day)); + return ( + date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day + ); +} + +function resolveSiteName(url: string | undefined): string | undefined { + if (!url) { + return undefined; + } + try { + return new URL(url).hostname; + } catch { + return undefined; + } +} + +async function throwWebSearchApiError(res: Response, providerLabel: string): Promise { + const detailResult = await readResponseText(res, { maxBytes: 64_000 }); + const detail = detailResult.text; + throw new Error(`${providerLabel} API error (${res.status}): ${detail || res.statusText}`); +} + +async function runPerplexitySearchApi(params: { + query: string; + apiKey: string; + count: number; + timeoutSeconds: number; + country?: string; + searchDomainFilter?: string[]; + searchRecencyFilter?: string; + searchLanguageFilter?: string[]; + searchAfterDate?: string; + searchBeforeDate?: string; + maxTokens?: number; + maxTokensPerPage?: number; +}): Promise< + Array<{ title: string; url: string; description: string; published?: string; siteName?: string }> +> { + const body: Record = { + query: params.query, + max_results: params.count, + }; + + if (params.country) { + body.country = params.country; + } + if (params.searchDomainFilter && params.searchDomainFilter.length > 0) { + body.search_domain_filter = params.searchDomainFilter; + } + if (params.searchRecencyFilter) { + body.search_recency_filter = params.searchRecencyFilter; + } + if (params.searchLanguageFilter && params.searchLanguageFilter.length > 0) { + body.search_language_filter = params.searchLanguageFilter; + } + if (params.searchAfterDate) { + body.search_after_date = params.searchAfterDate; + } + if (params.searchBeforeDate) { + body.search_before_date = params.searchBeforeDate; + } + if (params.maxTokens !== undefined) { + body.max_tokens = params.maxTokens; + } + if (params.maxTokensPerPage !== undefined) { + body.max_tokens_per_page = params.maxTokensPerPage; + } + + return withTrustedWebSearchEndpoint( + { + url: PERPLEXITY_SEARCH_ENDPOINT, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${params.apiKey}`, + "HTTP-Referer": "https://openclaw.ai", + "X-Title": "OpenClaw Web Search", + }, + body: JSON.stringify(body), + }, + }, + async (res) => { + if (!res.ok) { + return await throwWebSearchApiError(res, "Perplexity Search"); + } + + const data = (await res.json()) as PerplexitySearchApiResponse; + const results = Array.isArray(data.results) ? data.results : []; + + return results.map((entry) => { + const title = entry.title ?? ""; + const url = entry.url ?? ""; + const snippet = entry.snippet ?? ""; + return { + title: title ? wrapWebContent(title, "web_search") : "", + url, + description: snippet ? wrapWebContent(snippet, "web_search") : "", + published: entry.date ?? undefined, + siteName: resolveSiteName(url) || undefined, + }; + }); + }, + ); +} + +async function runPerplexitySearch(params: { + query: string; + apiKey: string; + baseUrl: string; + model: string; + timeoutSeconds: number; + freshness?: string; +}): Promise<{ content: string; citations: string[] }> { + const baseUrl = params.baseUrl.trim().replace(/\/$/, ""); + const endpoint = `${baseUrl}/chat/completions`; + const model = resolvePerplexityRequestModel(baseUrl, params.model); + + const body: Record = { + model, + messages: [ + { + role: "user", + content: params.query, + }, + ], + }; + + if (params.freshness) { + body.search_recency_filter = params.freshness; + } + + return withTrustedWebSearchEndpoint( + { + url: endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + "HTTP-Referer": "https://openclaw.ai", + "X-Title": "OpenClaw Web Search", + }, + body: JSON.stringify(body), + }, + }, + async (res) => { + if (!res.ok) { + return await throwWebSearchApiError(res, "Perplexity"); + } + + const data = (await res.json()) as PerplexitySearchResponse; + const content = data.choices?.[0]?.message?.content ?? "No response"; + // Prefer top-level citations; fall back to OpenRouter-style message annotations. + const citations = extractPerplexityCitations(data); + + return { content, citations }; + }, + ); +} + +async function runGrokSearch(params: { + query: string; + apiKey: string; + model: string; + timeoutSeconds: number; + inlineCitations: boolean; +}): Promise<{ + content: string; + citations: string[]; + inlineCitations?: GrokSearchResponse["inline_citations"]; +}> { + const body: Record = { + model: params.model, + input: [ + { + role: "user", + content: params.query, + }, + ], + tools: [{ type: "web_search" }], + }; + + // Note: xAI's /v1/responses endpoint does not support the `include` + // parameter (returns 400 "Argument not supported: include"). Inline + // citations are returned automatically when available — we just parse + // them from the response without requesting them explicitly (#12910). + + return withTrustedWebSearchEndpoint( + { + url: XAI_API_ENDPOINT, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + }, + body: JSON.stringify(body), + }, + }, + async (res) => { + if (!res.ok) { + return await throwWebSearchApiError(res, "xAI"); + } + + const data = (await res.json()) as GrokSearchResponse; + const { text: extractedText, annotationCitations } = extractGrokContent(data); + const content = extractedText ?? "No response"; + // Prefer top-level citations; fall back to annotation-derived ones + const citations = (data.citations ?? []).length > 0 ? data.citations! : annotationCitations; + const inlineCitations = data.inline_citations; + + return { content, citations, inlineCitations }; + }, + ); +} + +function extractKimiMessageText(message: KimiMessage | undefined): string | undefined { + const content = message?.content?.trim(); + if (content) { + return content; + } + const reasoning = message?.reasoning_content?.trim(); + return reasoning || undefined; +} + +function extractKimiCitations(data: KimiSearchResponse): string[] { + const citations = (data.search_results ?? []) + .map((entry) => entry.url?.trim()) + .filter((url): url is string => Boolean(url)); + + for (const toolCall of data.choices?.[0]?.message?.tool_calls ?? []) { + const rawArguments = toolCall.function?.arguments; + if (!rawArguments) { + continue; + } + try { + const parsed = JSON.parse(rawArguments) as { + search_results?: Array<{ url?: string }>; + url?: string; + }; + if (typeof parsed.url === "string" && parsed.url.trim()) { + citations.push(parsed.url.trim()); + } + for (const result of parsed.search_results ?? []) { + if (typeof result.url === "string" && result.url.trim()) { + citations.push(result.url.trim()); + } + } + } catch { + // ignore malformed tool arguments + } + } + + return [...new Set(citations)]; +} + +function buildKimiToolResultContent(data: KimiSearchResponse): string { + return JSON.stringify({ + search_results: (data.search_results ?? []).map((entry) => ({ + title: entry.title ?? "", + url: entry.url ?? "", + content: entry.content ?? "", + })), + }); +} + +async function runKimiSearch(params: { + query: string; + apiKey: string; + baseUrl: string; + model: string; + timeoutSeconds: number; +}): Promise<{ content: string; citations: string[] }> { + const baseUrl = params.baseUrl.trim().replace(/\/$/, ""); + const endpoint = `${baseUrl}/chat/completions`; + const messages: Array> = [ + { + role: "user", + content: params.query, + }, + ]; + const collectedCitations = new Set(); + const MAX_ROUNDS = 3; + + for (let round = 0; round < MAX_ROUNDS; round += 1) { + const nextResult = await withTrustedWebSearchEndpoint( + { + url: endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + }, + body: JSON.stringify({ + model: params.model, + messages, + tools: [KIMI_WEB_SEARCH_TOOL], + }), + }, + }, + async ( + res, + ): Promise<{ done: true; content: string; citations: string[] } | { done: false }> => { + if (!res.ok) { + return await throwWebSearchApiError(res, "Kimi"); + } + + const data = (await res.json()) as KimiSearchResponse; + for (const citation of extractKimiCitations(data)) { + collectedCitations.add(citation); + } + const choice = data.choices?.[0]; + const message = choice?.message; + const text = extractKimiMessageText(message); + const toolCalls = message?.tool_calls ?? []; + + if (choice?.finish_reason !== "tool_calls" || toolCalls.length === 0) { + return { done: true, content: text ?? "No response", citations: [...collectedCitations] }; + } + + messages.push({ + role: "assistant", + content: message?.content ?? "", + ...(message?.reasoning_content + ? { + reasoning_content: message.reasoning_content, + } + : {}), + tool_calls: toolCalls, + }); + + const toolContent = buildKimiToolResultContent(data); + let pushedToolResult = false; + for (const toolCall of toolCalls) { + const toolCallId = toolCall.id?.trim(); + if (!toolCallId) { + continue; + } + pushedToolResult = true; + messages.push({ + role: "tool", + tool_call_id: toolCallId, + content: toolContent, + }); + } + + if (!pushedToolResult) { + return { done: true, content: text ?? "No response", citations: [...collectedCitations] }; + } + + return { done: false }; + }, + ); + + if (nextResult.done) { + return { content: nextResult.content, citations: nextResult.citations }; + } + } + + return { + content: "Search completed but no final answer was produced.", + citations: [...collectedCitations], + }; +} + +function mapBraveLlmContextResults( + data: BraveLlmContextResponse, +): { url: string; title: string; snippets: string[]; siteName?: string }[] { + const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : []; + return genericResults.map((entry) => ({ + url: entry.url ?? "", + title: entry.title ?? "", + snippets: (entry.snippets ?? []).filter((s) => typeof s === "string" && s.length > 0), + siteName: resolveSiteName(entry.url) || undefined, + })); +} + +async function runBraveLlmContextSearch(params: { + query: string; + apiKey: string; + timeoutSeconds: number; + country?: string; + search_lang?: string; + freshness?: string; +}): Promise<{ + results: Array<{ + url: string; + title: string; + snippets: string[]; + siteName?: string; + }>; + sources?: BraveLlmContextResponse["sources"]; +}> { + const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT); + url.searchParams.set("q", params.query); + if (params.country) { + url.searchParams.set("country", params.country); + } + if (params.search_lang) { + url.searchParams.set("search_lang", params.search_lang); + } + if (params.freshness) { + url.searchParams.set("freshness", params.freshness); + } + + return withTrustedWebSearchEndpoint( + { + url: url.toString(), + timeoutSeconds: params.timeoutSeconds, + init: { + method: "GET", + headers: { + Accept: "application/json", + "X-Subscription-Token": params.apiKey, + }, + }, + }, + async (res) => { + if (!res.ok) { + const detailResult = await readResponseText(res, { maxBytes: 64_000 }); + const detail = detailResult.text; + throw new Error(`Brave LLM Context API error (${res.status}): ${detail || res.statusText}`); + } + + const data = (await res.json()) as BraveLlmContextResponse; + const mapped = mapBraveLlmContextResults(data); + + return { results: mapped, sources: data.sources }; + }, + ); +} + +async function runWebSearch(params: { + query: string; + count: number; + apiKey: string; + timeoutSeconds: number; + cacheTtlMs: number; + provider: (typeof SEARCH_PROVIDERS)[number]; + country?: string; + language?: string; + search_lang?: string; + ui_lang?: string; + freshness?: string; + dateAfter?: string; + dateBefore?: string; + searchDomainFilter?: string[]; + maxTokens?: number; + maxTokensPerPage?: number; + perplexityBaseUrl?: string; + perplexityModel?: string; + perplexityTransport?: PerplexityTransport; + grokModel?: string; + grokInlineCitations?: boolean; + geminiModel?: string; + kimiBaseUrl?: string; + kimiModel?: string; + braveMode?: "web" | "llm-context"; +}): Promise> { + const effectiveBraveMode = params.braveMode ?? "web"; + const providerSpecificKey = + params.provider === "perplexity" + ? `${params.perplexityTransport ?? "search_api"}:${params.perplexityBaseUrl ?? PERPLEXITY_DIRECT_BASE_URL}:${params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL}` + : params.provider === "grok" + ? `${params.grokModel ?? DEFAULT_GROK_MODEL}:${String(params.grokInlineCitations ?? false)}` + : params.provider === "gemini" + ? (params.geminiModel ?? DEFAULT_GEMINI_MODEL) + : params.provider === "kimi" + ? `${params.kimiBaseUrl ?? DEFAULT_KIMI_BASE_URL}:${params.kimiModel ?? DEFAULT_KIMI_MODEL}` + : ""; + const cacheKey = normalizeCacheKey( + params.provider === "brave" && effectiveBraveMode === "llm-context" + ? `${params.provider}:llm-context:${params.query}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.freshness || "default"}` + : `${params.provider}:${effectiveBraveMode}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}:${params.dateAfter || "default"}:${params.dateBefore || "default"}:${params.searchDomainFilter?.join(",") || "default"}:${params.maxTokens || "default"}:${params.maxTokensPerPage || "default"}:${providerSpecificKey}`, + ); + const cached = readCache(SEARCH_CACHE, cacheKey); + if (cached) { + return { ...cached.value, cached: true }; + } + + const start = Date.now(); + + if (params.provider === "perplexity") { + if (params.perplexityTransport === "chat_completions") { + const { content, citations } = await runPerplexitySearch({ + query: params.query, + apiKey: params.apiKey, + baseUrl: params.perplexityBaseUrl ?? DEFAULT_PERPLEXITY_BASE_URL, + model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL, + timeoutSeconds: params.timeoutSeconds, + freshness: params.freshness, + }); + + const payload = { + query: params.query, + provider: params.provider, + model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + content: wrapWebContent(content, "web_search"), + citations, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + } + + const results = await runPerplexitySearchApi({ + query: params.query, + apiKey: params.apiKey, + count: params.count, + timeoutSeconds: params.timeoutSeconds, + country: params.country, + searchDomainFilter: params.searchDomainFilter, + searchRecencyFilter: params.freshness, + searchLanguageFilter: params.language ? [params.language] : undefined, + searchAfterDate: params.dateAfter ? isoToPerplexityDate(params.dateAfter) : undefined, + searchBeforeDate: params.dateBefore ? isoToPerplexityDate(params.dateBefore) : undefined, + maxTokens: params.maxTokens, + maxTokensPerPage: params.maxTokensPerPage, + }); + + const payload = { + query: params.query, + provider: params.provider, + count: results.length, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + results, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + } + + if (params.provider === "grok") { + const { content, citations, inlineCitations } = await runGrokSearch({ + query: params.query, + apiKey: params.apiKey, + model: params.grokModel ?? DEFAULT_GROK_MODEL, + timeoutSeconds: params.timeoutSeconds, + inlineCitations: params.grokInlineCitations ?? false, + }); + + const payload = { + query: params.query, + provider: params.provider, + model: params.grokModel ?? DEFAULT_GROK_MODEL, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + content: wrapWebContent(content), + citations, + inlineCitations, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + } + + if (params.provider === "kimi") { + const { content, citations } = await runKimiSearch({ + query: params.query, + apiKey: params.apiKey, + baseUrl: params.kimiBaseUrl ?? DEFAULT_KIMI_BASE_URL, + model: params.kimiModel ?? DEFAULT_KIMI_MODEL, + timeoutSeconds: params.timeoutSeconds, + }); + + const payload = { + query: params.query, + provider: params.provider, + model: params.kimiModel ?? DEFAULT_KIMI_MODEL, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + content: wrapWebContent(content), + citations, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + } + + if (params.provider === "gemini") { + const geminiResult = await runGeminiSearch({ + query: params.query, + apiKey: params.apiKey, + model: params.geminiModel ?? DEFAULT_GEMINI_MODEL, + timeoutSeconds: params.timeoutSeconds, + }); + + const payload = { + query: params.query, + provider: params.provider, + model: params.geminiModel ?? DEFAULT_GEMINI_MODEL, + tookMs: Date.now() - start, // Includes redirect URL resolution time + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + content: wrapWebContent(geminiResult.content), + citations: geminiResult.citations, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + } + + if (params.provider !== "brave") { + throw new Error("Unsupported web search provider."); + } + + if (effectiveBraveMode === "llm-context") { + const { results: llmResults, sources } = await runBraveLlmContextSearch({ + query: params.query, + apiKey: params.apiKey, + timeoutSeconds: params.timeoutSeconds, + country: params.country, + search_lang: params.search_lang, + freshness: params.freshness, + }); + + const mapped = llmResults.map((entry) => ({ + title: entry.title ? wrapWebContent(entry.title, "web_search") : "", + url: entry.url, + snippets: entry.snippets.map((s) => wrapWebContent(s, "web_search")), + siteName: entry.siteName, + })); + + const payload = { + query: params.query, + provider: params.provider, + mode: "llm-context" as const, + count: mapped.length, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + results: mapped, + sources, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + } + + const url = new URL(BRAVE_SEARCH_ENDPOINT); + url.searchParams.set("q", params.query); + url.searchParams.set("count", String(params.count)); + if (params.country) { + url.searchParams.set("country", params.country); + } + if (params.search_lang || params.language) { + url.searchParams.set("search_lang", (params.search_lang || params.language)!); + } + if (params.ui_lang) { + url.searchParams.set("ui_lang", params.ui_lang); + } + if (params.freshness) { + url.searchParams.set("freshness", params.freshness); + } else if (params.dateAfter && params.dateBefore) { + url.searchParams.set("freshness", `${params.dateAfter}to${params.dateBefore}`); + } else if (params.dateAfter) { + url.searchParams.set( + "freshness", + `${params.dateAfter}to${new Date().toISOString().slice(0, 10)}`, + ); + } else if (params.dateBefore) { + url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`); + } + + const mapped = await withTrustedWebSearchEndpoint( + { + url: url.toString(), + timeoutSeconds: params.timeoutSeconds, + init: { + method: "GET", + headers: { + Accept: "application/json", + "X-Subscription-Token": params.apiKey, + }, + }, + }, + async (res) => { + if (!res.ok) { + const detailResult = await readResponseText(res, { maxBytes: 64_000 }); + const detail = detailResult.text; + throw new Error(`Brave Search API error (${res.status}): ${detail || res.statusText}`); + } + + const data = (await res.json()) as BraveSearchResponse; + const results = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : []; + return results.map((entry) => { + const description = entry.description ?? ""; + const title = entry.title ?? ""; + const url = entry.url ?? ""; + const rawSiteName = resolveSiteName(url); + return { + title: title ? wrapWebContent(title, "web_search") : "", + url, // Keep raw for tool chaining + description: description ? wrapWebContent(description, "web_search") : "", + published: entry.age || undefined, + siteName: rawSiteName || undefined, + }; + }); + }, + ); + + const payload = { + query: params.query, + provider: params.provider, + count: mapped.length, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + results: mapped, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; +} + +export function createWebSearchTool(options?: { + config?: OpenClawConfig; + sandboxed?: boolean; + runtimeWebSearch?: RuntimeWebSearchMetadata; +}): AnyAgentTool | null { + const search = resolveSearchConfig(options?.config); + if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) { + return null; + } + + const provider = + options?.runtimeWebSearch?.selectedProvider ?? + options?.runtimeWebSearch?.providerConfigured ?? + resolveSearchProvider(search); + const perplexityConfig = resolvePerplexityConfig(search); + const perplexitySchemaTransportHint = + options?.runtimeWebSearch?.perplexityTransport ?? + resolvePerplexitySchemaTransportHint(perplexityConfig); + const grokConfig = resolveGrokConfig(search); + const geminiConfig = resolveGeminiConfig(search); + const kimiConfig = resolveKimiConfig(search); + const braveConfig = resolveBraveConfig(search); + const braveMode = resolveBraveMode(braveConfig); + + const description = + provider === "perplexity" + ? perplexitySchemaTransportHint === "chat_completions" + ? "Search the web using Perplexity Sonar via Perplexity/OpenRouter chat completions. Returns AI-synthesized answers with citations from web-grounded search." + : "Search the web using Perplexity. Runtime routing decides between native Search API and Sonar chat-completions compatibility. Structured filters are available on the native Search API path." + : provider === "grok" + ? "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search." + : provider === "kimi" + ? "Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search." + : provider === "gemini" + ? "Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search." + : braveMode === "llm-context" + ? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding." + : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research."; + + return { + label: "Web Search", + name: "web_search", + description, + parameters: createWebSearchSchema({ + provider, + perplexityTransport: provider === "perplexity" ? perplexitySchemaTransportHint : undefined, + }), + execute: async (_toolCallId, args) => { + // Resolve Perplexity auth/transport lazily at execution time so unrelated providers + // do not touch Perplexity-only credential surfaces during tool construction. + const perplexityRuntime = + provider === "perplexity" ? resolvePerplexityTransport(perplexityConfig) : undefined; + const apiKey = + provider === "perplexity" + ? perplexityRuntime?.apiKey + : provider === "grok" + ? resolveGrokApiKey(grokConfig) + : provider === "kimi" + ? resolveKimiApiKey(kimiConfig) + : provider === "gemini" + ? resolveGeminiApiKey(geminiConfig) + : resolveSearchApiKey(search); + + if (!apiKey) { + return jsonResult(missingSearchKeyPayload(provider)); + } + + const supportsStructuredPerplexityFilters = + provider === "perplexity" && perplexityRuntime?.transport === "search_api"; + const params = args as Record; + const query = readStringParam(params, "query", { required: true }); + const count = + readNumberParam(params, "count", { integer: true }) ?? search?.maxResults ?? undefined; + const country = readStringParam(params, "country"); + if ( + country && + provider !== "brave" && + !(provider === "perplexity" && supportsStructuredPerplexityFilters) + ) { + return jsonResult({ + error: "unsupported_country", + message: + provider === "perplexity" + ? "country filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it." + : `country filtering is not supported by the ${provider} provider. Only Brave and Perplexity support country filtering.`, + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const language = readStringParam(params, "language"); + if ( + language && + provider !== "brave" && + !(provider === "perplexity" && supportsStructuredPerplexityFilters) + ) { + return jsonResult({ + error: "unsupported_language", + message: + provider === "perplexity" + ? "language filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it." + : `language filtering is not supported by the ${provider} provider. Only Brave and Perplexity support language filtering.`, + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if (language && provider === "perplexity" && !/^[a-z]{2}$/i.test(language)) { + return jsonResult({ + error: "invalid_language", + message: "language must be a 2-letter ISO 639-1 code like 'en', 'de', or 'fr'.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const search_lang = readStringParam(params, "search_lang"); + const ui_lang = readStringParam(params, "ui_lang"); + // For Brave, accept both `language` (unified) and `search_lang` + const normalizedBraveLanguageParams = + provider === "brave" + ? normalizeBraveLanguageParams({ search_lang: search_lang || language, ui_lang }) + : { search_lang: language, ui_lang }; + if (normalizedBraveLanguageParams.invalidField === "search_lang") { + return jsonResult({ + error: "invalid_search_lang", + message: + "search_lang must be a Brave-supported language code like 'en', 'en-gb', 'zh-hans', or 'zh-hant'.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if (normalizedBraveLanguageParams.invalidField === "ui_lang") { + return jsonResult({ + error: "invalid_ui_lang", + message: "ui_lang must be a language-region locale like 'en-US'.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const resolvedSearchLang = normalizedBraveLanguageParams.search_lang; + const resolvedUiLang = normalizedBraveLanguageParams.ui_lang; + if (resolvedUiLang && provider === "brave" && braveMode === "llm-context") { + return jsonResult({ + error: "unsupported_ui_lang", + message: + "ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const rawFreshness = readStringParam(params, "freshness"); + if (rawFreshness && provider !== "brave" && provider !== "perplexity") { + return jsonResult({ + error: "unsupported_freshness", + message: `freshness filtering is not supported by the ${provider} provider. Only Brave and Perplexity support freshness.`, + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if (rawFreshness && provider === "brave" && braveMode === "llm-context") { + return jsonResult({ + error: "unsupported_freshness", + message: + "freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const freshness = rawFreshness ? normalizeFreshness(rawFreshness, provider) : undefined; + if (rawFreshness && !freshness) { + return jsonResult({ + error: "invalid_freshness", + message: "freshness must be day, week, month, or year.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const rawDateAfter = readStringParam(params, "date_after"); + const rawDateBefore = readStringParam(params, "date_before"); + if (rawFreshness && (rawDateAfter || rawDateBefore)) { + return jsonResult({ + error: "conflicting_time_filters", + message: + "freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if ( + (rawDateAfter || rawDateBefore) && + provider !== "brave" && + !(provider === "perplexity" && supportsStructuredPerplexityFilters) + ) { + return jsonResult({ + error: "unsupported_date_filter", + message: + provider === "perplexity" + ? "date_after/date_before are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them." + : `date_after/date_before filtering is not supported by the ${provider} provider. Only Brave and Perplexity support date filtering.`, + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if ((rawDateAfter || rawDateBefore) && provider === "brave" && braveMode === "llm-context") { + return jsonResult({ + error: "unsupported_date_filter", + message: + "date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined; + if (rawDateAfter && !dateAfter) { + return jsonResult({ + error: "invalid_date", + message: "date_after must be YYYY-MM-DD format.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined; + if (rawDateBefore && !dateBefore) { + return jsonResult({ + error: "invalid_date", + message: "date_before must be YYYY-MM-DD format.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if (dateAfter && dateBefore && dateAfter > dateBefore) { + return jsonResult({ + error: "invalid_date_range", + message: "date_after must be before date_before.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const domainFilter = readStringArrayParam(params, "domain_filter"); + if ( + domainFilter && + domainFilter.length > 0 && + !(provider === "perplexity" && supportsStructuredPerplexityFilters) + ) { + return jsonResult({ + error: "unsupported_domain_filter", + message: + provider === "perplexity" + ? "domain_filter is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it." + : `domain_filter is not supported by the ${provider} provider. Only Perplexity supports domain filtering.`, + docs: "https://docs.openclaw.ai/tools/web", + }); + } + + if (domainFilter && domainFilter.length > 0) { + const hasDenylist = domainFilter.some((d) => d.startsWith("-")); + const hasAllowlist = domainFilter.some((d) => !d.startsWith("-")); + if (hasDenylist && hasAllowlist) { + return jsonResult({ + error: "invalid_domain_filter", + message: + "domain_filter cannot mix allowlist and denylist entries. Use either all positive entries (allowlist) or all entries prefixed with '-' (denylist).", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if (domainFilter.length > 20) { + return jsonResult({ + error: "invalid_domain_filter", + message: "domain_filter supports a maximum of 20 domains.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + } + + const maxTokens = readNumberParam(params, "max_tokens", { integer: true }); + const maxTokensPerPage = readNumberParam(params, "max_tokens_per_page", { integer: true }); + if ( + provider === "perplexity" && + perplexityRuntime?.transport === "chat_completions" && + (maxTokens !== undefined || maxTokensPerPage !== undefined) + ) { + return jsonResult({ + error: "unsupported_content_budget", + message: + "max_tokens and max_tokens_per_page are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + + const result = await runWebSearch({ + query, + count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT), + apiKey, + timeoutSeconds: resolveTimeoutSeconds(search?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS), + cacheTtlMs: resolveCacheTtlMs(search?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES), + provider, + country, + language, + search_lang: resolvedSearchLang, + ui_lang: resolvedUiLang, + freshness, + dateAfter, + dateBefore, + searchDomainFilter: domainFilter, + maxTokens: maxTokens ?? undefined, + maxTokensPerPage: maxTokensPerPage ?? undefined, + perplexityBaseUrl: perplexityRuntime?.baseUrl, + perplexityModel: perplexityRuntime?.model, + perplexityTransport: perplexityRuntime?.transport, + grokModel: resolveGrokModel(grokConfig), + grokInlineCitations: resolveGrokInlineCitations(grokConfig), + geminiModel: resolveGeminiModel(geminiConfig), + kimiBaseUrl: resolveKimiBaseUrl(kimiConfig), + kimiModel: resolveKimiModel(kimiConfig), + braveMode, + }); + return jsonResult(result); + }, + }; +} + +export const __testing = { + resolveSearchProvider, + inferPerplexityBaseUrlFromApiKey, + resolvePerplexityBaseUrl, + resolvePerplexityModel, + resolvePerplexityTransport, + isDirectPerplexityBaseUrl, + resolvePerplexityRequestModel, + resolvePerplexityApiKey, + normalizeBraveLanguageParams, + normalizeFreshness, + normalizeToIsoDate, + isoToPerplexityDate, + SEARCH_CACHE, + FRESHNESS_TO_RECENCY, + RECENCY_TO_FRESHNESS, + resolveGrokApiKey, + resolveGrokModel, + resolveGrokInlineCitations, + extractGrokContent, + resolveKimiApiKey, + resolveKimiModel, + resolveKimiBaseUrl, + extractKimiCitations, + resolveRedirectUrl: resolveCitationRedirectUrl, + resolveBraveMode, + mapBraveLlmContextResults, +} as const; diff --git a/src/agents/tools/web-search-plugin-factory.ts b/src/agents/tools/web-search-plugin-factory.ts new file mode 100644 index 00000000000..8022b2e354d --- /dev/null +++ b/src/agents/tools/web-search-plugin-factory.ts @@ -0,0 +1,85 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { WebSearchProviderPlugin } from "../../plugins/types.js"; +import { createWebSearchTool as createLegacyWebSearchTool } from "./web-search-core.js"; + +function cloneWithDescriptors(value: T | undefined): T { + const next = Object.create(Object.getPrototypeOf(value ?? {})) as T; + if (value) { + Object.defineProperties(next, Object.getOwnPropertyDescriptors(value)); + } + return next; +} + +function withForcedProvider(config: OpenClawConfig | undefined, provider: string): OpenClawConfig { + const next = cloneWithDescriptors(config ?? {}); + const tools = cloneWithDescriptors(next.tools ?? {}); + const web = cloneWithDescriptors(tools.web ?? {}); + const search = cloneWithDescriptors(web.search ?? {}); + + search.provider = provider; + web.search = search; + tools.web = web; + next.tools = tools; + + return next; +} + +export function createPluginBackedWebSearchProvider( + provider: Omit, +): WebSearchProviderPlugin { + return { + ...provider, + createTool: (ctx) => { + const tool = createLegacyWebSearchTool({ + config: withForcedProvider(ctx.config, provider.id), + runtimeWebSearch: ctx.runtimeMetadata, + }); + if (!tool) { + return null; + } + return { + description: tool.description, + parameters: tool.parameters as Record, + execute: async (args) => { + const result = await tool.execute(`web-search:${provider.id}`, args); + return (result.details ?? {}) as Record; + }, + }; + }, + }; +} + +export function getTopLevelCredentialValue(searchConfig?: Record): unknown { + return searchConfig?.apiKey; +} + +export function setTopLevelCredentialValue( + searchConfigTarget: Record, + value: unknown, +): void { + searchConfigTarget.apiKey = value; +} + +export function getScopedCredentialValue( + searchConfig: Record | undefined, + key: string, +): unknown { + const scoped = searchConfig?.[key]; + if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { + return undefined; + } + return (scoped as Record).apiKey; +} + +export function setScopedCredentialValue( + searchConfigTarget: Record, + key: string, + value: unknown, +): void { + const scoped = searchConfigTarget[key]; + if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { + searchConfigTarget[key] = { apiKey: value }; + return; + } + (scoped as Record).apiKey = value; +} diff --git a/src/agents/tools/web-search.redirect.test.ts b/src/agents/tools/web-search.redirect.test.ts index cac014d7e9a..d00c6a31995 100644 --- a/src/agents/tools/web-search.redirect.test.ts +++ b/src/agents/tools/web-search.redirect.test.ts @@ -1,48 +1,48 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({ - fetchWithSsrFGuardMock: vi.fn(), +const { withStrictWebToolsEndpointMock } = vi.hoisted(() => ({ + withStrictWebToolsEndpointMock: vi.fn(), })); -vi.mock("../../infra/net/fetch-guard.js", () => ({ - fetchWithSsrFGuard: fetchWithSsrFGuardMock, +vi.mock("./web-guarded-fetch.js", () => ({ + withStrictWebToolsEndpoint: withStrictWebToolsEndpointMock, })); -import { __testing } from "./web-search.js"; - describe("web_search redirect resolution hardening", () => { - const { resolveRedirectUrl } = __testing; + async function resolveRedirectUrl() { + const module = await import("./web-search-citation-redirect.js"); + return module.resolveCitationRedirectUrl; + } beforeEach(() => { - fetchWithSsrFGuardMock.mockReset(); + vi.resetModules(); + withStrictWebToolsEndpointMock.mockReset(); }); it("resolves redirects via SSRF-guarded HEAD requests", async () => { - const release = vi.fn(async () => {}); - fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response(null, { status: 200 }), - finalUrl: "https://example.com/final", - release, + const resolve = await resolveRedirectUrl(); + withStrictWebToolsEndpointMock.mockImplementation(async (_params, run) => { + return await run({ + response: new Response(null, { status: 200 }), + finalUrl: "https://example.com/final", + }); }); - const resolved = await resolveRedirectUrl("https://example.com/start"); + const resolved = await resolve("https://example.com/start"); expect(resolved).toBe("https://example.com/final"); - expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect(withStrictWebToolsEndpointMock).toHaveBeenCalledWith( expect.objectContaining({ url: "https://example.com/start", timeoutMs: 5000, init: { method: "HEAD" }, }), + expect.any(Function), ); - expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0]?.proxy).toBeUndefined(); - expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0]?.policy).toBeUndefined(); - expect(release).toHaveBeenCalledTimes(1); }); it("falls back to the original URL when guarded resolution fails", async () => { - fetchWithSsrFGuardMock.mockRejectedValue(new Error("blocked")); - await expect(resolveRedirectUrl("https://example.com/start")).resolves.toBe( - "https://example.com/start", - ); + const resolve = await resolveRedirectUrl(); + withStrictWebToolsEndpointMock.mockRejectedValue(new Error("blocked")); + await expect(resolve("https://example.com/start")).resolves.toBe("https://example.com/start"); }); }); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 6e9518f1ede..869da014d45 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -1,286 +1,12 @@ -import { Type } from "@sinclair/typebox"; -import { formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/config.js"; import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js"; import { logVerbose } from "../../globals.js"; -import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.js"; -import { wrapWebContent } from "../../security/external-content.js"; +import { resolvePluginWebSearchProviders } from "../../plugins/web-search-providers.js"; +import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.types.js"; import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; import type { AnyAgentTool } from "./common.js"; -import { jsonResult, readNumberParam, readStringArrayParam, readStringParam } from "./common.js"; -import { withTrustedWebToolsEndpoint } from "./web-guarded-fetch.js"; -import { resolveCitationRedirectUrl } from "./web-search-citation-redirect.js"; -import { - CacheEntry, - DEFAULT_CACHE_TTL_MINUTES, - DEFAULT_TIMEOUT_SECONDS, - normalizeCacheKey, - readCache, - readResponseText, - resolveCacheTtlMs, - resolveTimeoutSeconds, - writeCache, -} from "./web-shared.js"; - -const SEARCH_PROVIDERS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const; -const DEFAULT_SEARCH_COUNT = 5; -const MAX_SEARCH_COUNT = 10; - -const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"; -const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context"; -const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1"; -const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai"; -const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search"; -const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro"; -const PERPLEXITY_KEY_PREFIXES = ["pplx-"]; -const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; - -const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses"; -const DEFAULT_GROK_MODEL = "grok-4-1-fast"; -const DEFAULT_KIMI_BASE_URL = "https://api.moonshot.ai/v1"; -const DEFAULT_KIMI_MODEL = "moonshot-v1-128k"; -const KIMI_WEB_SEARCH_TOOL = { - type: "builtin_function", - function: { name: "$web_search" }, -} as const; - -const SEARCH_CACHE = new Map>>(); -const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]); -const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/; -const BRAVE_SEARCH_LANG_CODES = new Set([ - "ar", - "eu", - "bn", - "bg", - "ca", - "zh-hans", - "zh-hant", - "hr", - "cs", - "da", - "nl", - "en", - "en-gb", - "et", - "fi", - "fr", - "gl", - "de", - "el", - "gu", - "he", - "hi", - "hu", - "is", - "it", - "jp", - "kn", - "ko", - "lv", - "lt", - "ms", - "ml", - "mr", - "nb", - "pl", - "pt-br", - "pt-pt", - "pa", - "ro", - "ru", - "sr", - "sk", - "sl", - "es", - "sv", - "ta", - "te", - "th", - "tr", - "uk", - "vi", -]); -const BRAVE_SEARCH_LANG_ALIASES: Record = { - ja: "jp", - zh: "zh-hans", - "zh-cn": "zh-hans", - "zh-hk": "zh-hant", - "zh-sg": "zh-hans", - "zh-tw": "zh-hant", -}; -const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i; -const PERPLEXITY_RECENCY_VALUES = new Set(["day", "week", "month", "year"]); - -const FRESHNESS_TO_RECENCY: Record = { - pd: "day", - pw: "week", - pm: "month", - py: "year", -}; -const RECENCY_TO_FRESHNESS: Record = { - day: "pd", - week: "pw", - month: "pm", - year: "py", -}; - -const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; -const PERPLEXITY_DATE_PATTERN = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/; - -function isoToPerplexityDate(iso: string): string | undefined { - const match = iso.match(ISO_DATE_PATTERN); - if (!match) { - return undefined; - } - const [, year, month, day] = match; - return `${parseInt(month, 10)}/${parseInt(day, 10)}/${year}`; -} - -function normalizeToIsoDate(value: string): string | undefined { - const trimmed = value.trim(); - if (ISO_DATE_PATTERN.test(trimmed)) { - return isValidIsoDate(trimmed) ? trimmed : undefined; - } - const match = trimmed.match(PERPLEXITY_DATE_PATTERN); - if (match) { - const [, month, day, year] = match; - const iso = `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`; - return isValidIsoDate(iso) ? iso : undefined; - } - return undefined; -} - -function createWebSearchSchema(params: { - provider: (typeof SEARCH_PROVIDERS)[number]; - perplexityTransport?: PerplexityTransport; -}) { - const querySchema = { - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-10).", - minimum: 1, - maximum: MAX_SEARCH_COUNT, - }), - ), - } as const; - - const filterSchema = { - country: Type.Optional( - Type.String({ - description: - "2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.", - }), - ), - language: Type.Optional( - Type.String({ - description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').", - }), - ), - freshness: Type.Optional( - Type.String({ - description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.", - }), - ), - date_after: Type.Optional( - Type.String({ - description: "Only results published after this date (YYYY-MM-DD).", - }), - ), - date_before: Type.Optional( - Type.String({ - description: "Only results published before this date (YYYY-MM-DD).", - }), - ), - } as const; - - const perplexityStructuredFilterSchema = { - country: Type.Optional( - Type.String({ - description: - "Native Perplexity Search API only. 2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.", - }), - ), - language: Type.Optional( - Type.String({ - description: - "Native Perplexity Search API only. ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').", - }), - ), - date_after: Type.Optional( - Type.String({ - description: - "Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).", - }), - ), - date_before: Type.Optional( - Type.String({ - description: - "Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).", - }), - ), - } as const; - - if (params.provider === "brave") { - return Type.Object({ - ...querySchema, - ...filterSchema, - search_lang: Type.Optional( - Type.String({ - description: - "Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').", - }), - ), - ui_lang: Type.Optional( - Type.String({ - description: - "Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.", - }), - ), - }); - } - - if (params.provider === "perplexity") { - if (params.perplexityTransport === "chat_completions") { - return Type.Object({ - ...querySchema, - freshness: filterSchema.freshness, - }); - } - return Type.Object({ - ...querySchema, - freshness: filterSchema.freshness, - ...perplexityStructuredFilterSchema, - domain_filter: Type.Optional( - Type.Array(Type.String(), { - description: - "Native Perplexity Search API only. Domain filter (max 20). Allowlist: ['nature.com'] or denylist: ['-reddit.com']. Cannot mix.", - }), - ), - max_tokens: Type.Optional( - Type.Number({ - description: - "Native Perplexity Search API only. Total content budget across all results (default: 25000, max: 1000000).", - minimum: 1, - maximum: 1000000, - }), - ), - max_tokens_per_page: Type.Optional( - Type.Number({ - description: - "Native Perplexity Search API only. Max tokens extracted per page (default: 2048).", - minimum: 1, - }), - ), - }); - } - - // grok, gemini, kimi, etc. - return Type.Object({ - ...querySchema, - ...filterSchema, - }); -} +import { jsonResult } from "./common.js"; +import { __testing as coreTesting } from "./web-search-core.js"; type WebSearchConfig = NonNullable["web"] extends infer Web ? Web extends { search?: infer Search } @@ -288,248 +14,6 @@ type WebSearchConfig = NonNullable["web"] extends infer : undefined : undefined; -type BraveSearchResult = { - title?: string; - url?: string; - description?: string; - age?: string; -}; - -type BraveSearchResponse = { - web?: { - results?: BraveSearchResult[]; - }; -}; - -type BraveLlmContextResult = { url: string; title: string; snippets: string[] }; -type BraveLlmContextResponse = { - grounding: { generic?: BraveLlmContextResult[] }; - sources?: { url?: string; hostname?: string; date?: string }[]; -}; - -type BraveConfig = { - mode?: string; -}; - -type PerplexityConfig = { - apiKey?: string; - baseUrl?: string; - model?: string; -}; - -type PerplexityApiKeySource = "config" | "perplexity_env" | "openrouter_env" | "none"; -type PerplexityTransport = "search_api" | "chat_completions"; -type PerplexityBaseUrlHint = "direct" | "openrouter"; - -type GrokConfig = { - apiKey?: string; - model?: string; - inlineCitations?: boolean; -}; - -type KimiConfig = { - apiKey?: string; - baseUrl?: string; - model?: string; -}; - -type GrokSearchResponse = { - output?: Array<{ - type?: string; - role?: string; - text?: string; // present when type === "output_text" (top-level output_text block) - content?: Array<{ - type?: string; - text?: string; - annotations?: Array<{ - type?: string; - url?: string; - start_index?: number; - end_index?: number; - }>; - }>; - annotations?: Array<{ - type?: string; - url?: string; - start_index?: number; - end_index?: number; - }>; - }>; - output_text?: string; // deprecated field - kept for backwards compatibility - citations?: string[]; - inline_citations?: Array<{ - start_index: number; - end_index: number; - url: string; - }>; -}; - -type KimiToolCall = { - id?: string; - type?: string; - function?: { - name?: string; - arguments?: string; - }; -}; - -type KimiMessage = { - role?: string; - content?: string; - reasoning_content?: string; - tool_calls?: KimiToolCall[]; -}; - -type KimiSearchResponse = { - choices?: Array<{ - finish_reason?: string; - message?: KimiMessage; - }>; - search_results?: Array<{ - title?: string; - url?: string; - content?: string; - }>; -}; - -type PerplexitySearchResponse = { - choices?: Array<{ - message?: { - content?: string; - annotations?: Array<{ - type?: string; - url?: string; - url_citation?: { - url?: string; - title?: string; - start_index?: number; - end_index?: number; - }; - }>; - }; - }>; - citations?: string[]; -}; - -type PerplexitySearchApiResult = { - title?: string; - url?: string; - snippet?: string; - date?: string; - last_updated?: string; -}; - -type PerplexitySearchApiResponse = { - results?: PerplexitySearchApiResult[]; - id?: string; -}; - -function extractPerplexityCitations(data: PerplexitySearchResponse): string[] { - const normalizeUrl = (value: unknown): string | undefined => { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; - }; - - const topLevel = (data.citations ?? []) - .map(normalizeUrl) - .filter((url): url is string => Boolean(url)); - if (topLevel.length > 0) { - return [...new Set(topLevel)]; - } - - const citations: string[] = []; - for (const choice of data.choices ?? []) { - for (const annotation of choice.message?.annotations ?? []) { - if (annotation.type !== "url_citation") { - continue; - } - const url = normalizeUrl(annotation.url_citation?.url ?? annotation.url); - if (url) { - citations.push(url); - } - } - } - - return [...new Set(citations)]; -} - -function extractGrokContent(data: GrokSearchResponse): { - text: string | undefined; - annotationCitations: string[]; -} { - // xAI Responses API format: find the message output with text content - for (const output of data.output ?? []) { - if (output.type === "message") { - for (const block of output.content ?? []) { - if (block.type === "output_text" && typeof block.text === "string" && block.text) { - const urls = (block.annotations ?? []) - .filter((a) => a.type === "url_citation" && typeof a.url === "string") - .map((a) => a.url as string); - return { text: block.text, annotationCitations: [...new Set(urls)] }; - } - } - } - // Some xAI responses place output_text blocks directly in the output array - // without a message wrapper. - if ( - output.type === "output_text" && - "text" in output && - typeof output.text === "string" && - output.text - ) { - const rawAnnotations = - "annotations" in output && Array.isArray(output.annotations) ? output.annotations : []; - const urls = rawAnnotations - .filter( - (a: Record) => a.type === "url_citation" && typeof a.url === "string", - ) - .map((a: Record) => a.url as string); - return { text: output.text, annotationCitations: [...new Set(urls)] }; - } - } - // Fallback: deprecated output_text field - const text = typeof data.output_text === "string" ? data.output_text : undefined; - return { text, annotationCitations: [] }; -} - -type GeminiConfig = { - apiKey?: string; - model?: string; -}; - -type GeminiGroundingResponse = { - candidates?: Array<{ - content?: { - parts?: Array<{ - text?: string; - }>; - }; - groundingMetadata?: { - groundingChunks?: Array<{ - web?: { - uri?: string; - title?: string; - }; - }>; - searchEntryPoint?: { - renderedContent?: string; - }; - webSearchQueries?: string[]; - }; - }>; - error?: { - code?: number; - message?: string; - status?: string; - }; -}; - -const DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"; -const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta"; - function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig { const search = cfg?.tools?.web?.search; if (!search || typeof search !== "object") { @@ -548,1344 +32,66 @@ function resolveSearchEnabled(params: { search?: WebSearchConfig; sandboxed?: bo return true; } -function resolveSearchApiKey(search?: WebSearchConfig): string | undefined { - const fromConfigRaw = - search && "apiKey" in search - ? normalizeResolvedSecretInputString({ - value: search.apiKey, - path: "tools.web.search.apiKey", - }) - : undefined; - const fromConfig = normalizeSecretInput(fromConfigRaw); - const fromEnv = normalizeSecretInput(process.env.BRAVE_API_KEY); - return fromConfig || fromEnv || undefined; +function readProviderEnvValue(envVars: string[]): string | undefined { + for (const envVar of envVars) { + const value = normalizeSecretInput(process.env[envVar]); + if (value) { + return value; + } + } + return undefined; } -function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) { - if (provider === "brave") { - return { - error: "missing_brave_api_key", - message: `web_search (brave) needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`, - docs: "https://docs.openclaw.ai/tools/web", - }; +function hasProviderCredential(providerId: string, search: WebSearchConfig | undefined): boolean { + const providers = resolvePluginWebSearchProviders({ + bundledAllowlistCompat: true, + }); + const provider = providers.find((entry) => entry.id === providerId); + if (!provider) { + return false; } - if (provider === "gemini") { - return { - error: "missing_gemini_api_key", - message: - "web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (provider === "grok") { - return { - error: "missing_xai_api_key", - message: - "web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure tools.web.search.grok.apiKey.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (provider === "kimi") { - return { - error: "missing_kimi_api_key", - message: - "web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure tools.web.search.kimi.apiKey.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - return { - error: "missing_perplexity_api_key", - message: - "web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.", - docs: "https://docs.openclaw.ai/tools/web", - }; + const rawValue = provider.getCredentialValue(search as Record | undefined); + const fromConfig = normalizeSecretInput( + normalizeResolvedSecretInputString({ + value: rawValue, + path: + providerId === "brave" + ? "tools.web.search.apiKey" + : `tools.web.search.${providerId}.apiKey`, + }), + ); + return Boolean(fromConfig || readProviderEnvValue(provider.envVars)); } -function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDERS)[number] { +function resolveSearchProvider(search?: WebSearchConfig): string { + const providers = resolvePluginWebSearchProviders({ + bundledAllowlistCompat: true, + }); const raw = search && "provider" in search && typeof search.provider === "string" ? search.provider.trim().toLowerCase() : ""; - if (raw === "brave") { - return "brave"; - } - if (raw === "gemini") { - return "gemini"; - } - if (raw === "grok") { - return "grok"; - } - if (raw === "kimi") { - return "kimi"; - } - if (raw === "perplexity") { - return "perplexity"; + + if (raw) { + const explicit = providers.find((provider) => provider.id === raw); + if (explicit) { + return explicit.id; + } } - // Auto-detect provider from available API keys (alphabetical order) - if (raw === "") { - // Brave - if (resolveSearchApiKey(search)) { + if (!raw) { + for (const provider of providers) { + if (!hasProviderCredential(provider.id, search)) { + continue; + } logVerbose( - 'web_search: no provider configured, auto-detected "brave" from available API keys', + `web_search: no provider configured, auto-detected "${provider.id}" from available API keys`, ); - return "brave"; - } - // Gemini - const geminiConfig = resolveGeminiConfig(search); - if (resolveGeminiApiKey(geminiConfig)) { - logVerbose( - 'web_search: no provider configured, auto-detected "gemini" from available API keys', - ); - return "gemini"; - } - // Grok - const grokConfig = resolveGrokConfig(search); - if (resolveGrokApiKey(grokConfig)) { - logVerbose( - 'web_search: no provider configured, auto-detected "grok" from available API keys', - ); - return "grok"; - } - // Kimi - const kimiConfig = resolveKimiConfig(search); - if (resolveKimiApiKey(kimiConfig)) { - logVerbose( - 'web_search: no provider configured, auto-detected "kimi" from available API keys', - ); - return "kimi"; - } - // Perplexity - const perplexityConfig = resolvePerplexityConfig(search); - const { apiKey: perplexityKey } = resolvePerplexityApiKey(perplexityConfig); - if (perplexityKey) { - logVerbose( - 'web_search: no provider configured, auto-detected "perplexity" from available API keys', - ); - return "perplexity"; + return provider.id; } } - return "brave"; -} - -function resolveBraveConfig(search?: WebSearchConfig): BraveConfig { - if (!search || typeof search !== "object") { - return {}; - } - const brave = "brave" in search ? search.brave : undefined; - if (!brave || typeof brave !== "object") { - return {}; - } - return brave as BraveConfig; -} - -function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" { - return brave.mode === "llm-context" ? "llm-context" : "web"; -} - -function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig { - if (!search || typeof search !== "object") { - return {}; - } - const perplexity = "perplexity" in search ? search.perplexity : undefined; - if (!perplexity || typeof perplexity !== "object") { - return {}; - } - return perplexity as PerplexityConfig; -} - -function resolvePerplexityApiKey(perplexity?: PerplexityConfig): { - apiKey?: string; - source: PerplexityApiKeySource; -} { - const fromConfig = normalizeApiKey(perplexity?.apiKey); - if (fromConfig) { - return { apiKey: fromConfig, source: "config" }; - } - - const fromEnvPerplexity = normalizeApiKey(process.env.PERPLEXITY_API_KEY); - if (fromEnvPerplexity) { - return { apiKey: fromEnvPerplexity, source: "perplexity_env" }; - } - - const fromEnvOpenRouter = normalizeApiKey(process.env.OPENROUTER_API_KEY); - if (fromEnvOpenRouter) { - return { apiKey: fromEnvOpenRouter, source: "openrouter_env" }; - } - - return { apiKey: undefined, source: "none" }; -} - -function normalizeApiKey(key: unknown): string { - return normalizeSecretInput(key); -} - -function inferPerplexityBaseUrlFromApiKey(apiKey?: string): PerplexityBaseUrlHint | undefined { - if (!apiKey) { - return undefined; - } - const normalized = apiKey.toLowerCase(); - if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { - return "direct"; - } - if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { - return "openrouter"; - } - return undefined; -} - -function resolvePerplexityBaseUrl( - perplexity?: PerplexityConfig, - authSource: PerplexityApiKeySource = "none", // pragma: allowlist secret - configuredKey?: string, -): string { - const fromConfig = - perplexity && "baseUrl" in perplexity && typeof perplexity.baseUrl === "string" - ? perplexity.baseUrl.trim() - : ""; - if (fromConfig) { - return fromConfig; - } - if (authSource === "perplexity_env") { - return PERPLEXITY_DIRECT_BASE_URL; - } - if (authSource === "openrouter_env") { - return DEFAULT_PERPLEXITY_BASE_URL; - } - if (authSource === "config") { - const inferred = inferPerplexityBaseUrlFromApiKey(configuredKey); - if (inferred === "openrouter") { - return DEFAULT_PERPLEXITY_BASE_URL; - } - return PERPLEXITY_DIRECT_BASE_URL; - } - return DEFAULT_PERPLEXITY_BASE_URL; -} - -function resolvePerplexityModel(perplexity?: PerplexityConfig): string { - const fromConfig = - perplexity && "model" in perplexity && typeof perplexity.model === "string" - ? perplexity.model.trim() - : ""; - return fromConfig || DEFAULT_PERPLEXITY_MODEL; -} - -function isDirectPerplexityBaseUrl(baseUrl: string): boolean { - const trimmed = baseUrl.trim(); - if (!trimmed) { - return false; - } - try { - return new URL(trimmed).hostname.toLowerCase() === "api.perplexity.ai"; - } catch { - return false; - } -} - -function resolvePerplexityRequestModel(baseUrl: string, model: string): string { - if (!isDirectPerplexityBaseUrl(baseUrl)) { - return model; - } - return model.startsWith("perplexity/") ? model.slice("perplexity/".length) : model; -} - -function resolvePerplexityTransport(perplexity?: PerplexityConfig): { - apiKey?: string; - source: PerplexityApiKeySource; - baseUrl: string; - model: string; - transport: PerplexityTransport; -} { - const auth = resolvePerplexityApiKey(perplexity); - const baseUrl = resolvePerplexityBaseUrl(perplexity, auth.source, auth.apiKey); - const model = resolvePerplexityModel(perplexity); - const hasLegacyOverride = Boolean( - (perplexity?.baseUrl && perplexity.baseUrl.trim()) || - (perplexity?.model && perplexity.model.trim()), - ); - return { - ...auth, - baseUrl, - model, - transport: - hasLegacyOverride || !isDirectPerplexityBaseUrl(baseUrl) ? "chat_completions" : "search_api", - }; -} - -function resolvePerplexitySchemaTransportHint( - perplexity?: PerplexityConfig, -): PerplexityTransport | undefined { - const hasLegacyOverride = Boolean( - (perplexity?.baseUrl && perplexity.baseUrl.trim()) || - (perplexity?.model && perplexity.model.trim()), - ); - return hasLegacyOverride ? "chat_completions" : undefined; -} - -function resolveGrokConfig(search?: WebSearchConfig): GrokConfig { - if (!search || typeof search !== "object") { - return {}; - } - const grok = "grok" in search ? search.grok : undefined; - if (!grok || typeof grok !== "object") { - return {}; - } - return grok as GrokConfig; -} - -function resolveGrokApiKey(grok?: GrokConfig): string | undefined { - const fromConfig = normalizeApiKey(grok?.apiKey); - if (fromConfig) { - return fromConfig; - } - const fromEnv = normalizeApiKey(process.env.XAI_API_KEY); - return fromEnv || undefined; -} - -function resolveGrokModel(grok?: GrokConfig): string { - const fromConfig = - grok && "model" in grok && typeof grok.model === "string" ? grok.model.trim() : ""; - return fromConfig || DEFAULT_GROK_MODEL; -} - -function resolveGrokInlineCitations(grok?: GrokConfig): boolean { - return grok?.inlineCitations === true; -} - -function resolveKimiConfig(search?: WebSearchConfig): KimiConfig { - if (!search || typeof search !== "object") { - return {}; - } - const kimi = "kimi" in search ? search.kimi : undefined; - if (!kimi || typeof kimi !== "object") { - return {}; - } - return kimi as KimiConfig; -} - -function resolveKimiApiKey(kimi?: KimiConfig): string | undefined { - const fromConfig = normalizeApiKey(kimi?.apiKey); - if (fromConfig) { - return fromConfig; - } - const fromEnvKimi = normalizeApiKey(process.env.KIMI_API_KEY); - if (fromEnvKimi) { - return fromEnvKimi; - } - const fromEnvMoonshot = normalizeApiKey(process.env.MOONSHOT_API_KEY); - return fromEnvMoonshot || undefined; -} - -function resolveKimiModel(kimi?: KimiConfig): string { - const fromConfig = - kimi && "model" in kimi && typeof kimi.model === "string" ? kimi.model.trim() : ""; - return fromConfig || DEFAULT_KIMI_MODEL; -} - -function resolveKimiBaseUrl(kimi?: KimiConfig): string { - const fromConfig = - kimi && "baseUrl" in kimi && typeof kimi.baseUrl === "string" ? kimi.baseUrl.trim() : ""; - return fromConfig || DEFAULT_KIMI_BASE_URL; -} - -function resolveGeminiConfig(search?: WebSearchConfig): GeminiConfig { - if (!search || typeof search !== "object") { - return {}; - } - const gemini = "gemini" in search ? search.gemini : undefined; - if (!gemini || typeof gemini !== "object") { - return {}; - } - return gemini as GeminiConfig; -} - -function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined { - const fromConfig = normalizeApiKey(gemini?.apiKey); - if (fromConfig) { - return fromConfig; - } - const fromEnv = normalizeApiKey(process.env.GEMINI_API_KEY); - return fromEnv || undefined; -} - -function resolveGeminiModel(gemini?: GeminiConfig): string { - const fromConfig = - gemini && "model" in gemini && typeof gemini.model === "string" ? gemini.model.trim() : ""; - return fromConfig || DEFAULT_GEMINI_MODEL; -} - -async function withTrustedWebSearchEndpoint( - params: { - url: string; - timeoutSeconds: number; - init: RequestInit; - }, - run: (response: Response) => Promise, -): Promise { - return withTrustedWebToolsEndpoint( - { - url: params.url, - init: params.init, - timeoutSeconds: params.timeoutSeconds, - }, - async ({ response }) => run(response), - ); -} - -async function runGeminiSearch(params: { - query: string; - apiKey: string; - model: string; - timeoutSeconds: number; -}): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> { - const endpoint = `${GEMINI_API_BASE}/models/${params.model}:generateContent`; - - return withTrustedWebSearchEndpoint( - { - url: endpoint, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-goog-api-key": params.apiKey, - }, - body: JSON.stringify({ - contents: [ - { - parts: [{ text: params.query }], - }, - ], - tools: [{ google_search: {} }], - }), - }, - }, - async (res) => { - if (!res.ok) { - const detailResult = await readResponseText(res, { maxBytes: 64_000 }); - // Strip API key from any error detail to prevent accidental key leakage in logs - const safeDetail = (detailResult.text || res.statusText).replace( - /key=[^&\s]+/gi, - "key=***", - ); - throw new Error(`Gemini API error (${res.status}): ${safeDetail}`); - } - - let data: GeminiGroundingResponse; - try { - data = (await res.json()) as GeminiGroundingResponse; - } catch (err) { - const safeError = String(err).replace(/key=[^&\s]+/gi, "key=***"); - throw new Error(`Gemini API returned invalid JSON: ${safeError}`, { cause: err }); - } - - if (data.error) { - const rawMsg = data.error.message || data.error.status || "unknown"; - const safeMsg = rawMsg.replace(/key=[^&\s]+/gi, "key=***"); - throw new Error(`Gemini API error (${data.error.code}): ${safeMsg}`); - } - - const candidate = data.candidates?.[0]; - const content = - candidate?.content?.parts - ?.map((p) => p.text) - .filter(Boolean) - .join("\n") ?? "No response"; - - const groundingChunks = candidate?.groundingMetadata?.groundingChunks ?? []; - const rawCitations = groundingChunks - .filter((chunk) => chunk.web?.uri) - .map((chunk) => ({ - url: chunk.web!.uri!, - title: chunk.web?.title || undefined, - })); - - // Resolve Google grounding redirect URLs to direct URLs with concurrency cap. - // Gemini typically returns 3-8 citations; cap at 10 concurrent to be safe. - const MAX_CONCURRENT_REDIRECTS = 10; - const citations: Array<{ url: string; title?: string }> = []; - for (let i = 0; i < rawCitations.length; i += MAX_CONCURRENT_REDIRECTS) { - const batch = rawCitations.slice(i, i + MAX_CONCURRENT_REDIRECTS); - const resolved = await Promise.all( - batch.map(async (citation) => { - const resolvedUrl = await resolveCitationRedirectUrl(citation.url); - return { ...citation, url: resolvedUrl }; - }), - ); - citations.push(...resolved); - } - - return { content, citations }; - }, - ); -} - -function resolveSearchCount(value: unknown, fallback: number): number { - const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback; - const clamped = Math.max(1, Math.min(MAX_SEARCH_COUNT, Math.floor(parsed))); - return clamped; -} - -function normalizeBraveSearchLang(value: string | undefined): string | undefined { - if (!value) { - return undefined; - } - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - const canonical = BRAVE_SEARCH_LANG_ALIASES[trimmed.toLowerCase()] ?? trimmed.toLowerCase(); - if (!BRAVE_SEARCH_LANG_CODES.has(canonical)) { - return undefined; - } - return canonical; -} - -function normalizeBraveUiLang(value: string | undefined): string | undefined { - if (!value) { - return undefined; - } - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - const match = trimmed.match(BRAVE_UI_LANG_LOCALE); - if (!match) { - return undefined; - } - const [, language, region] = match; - return `${language.toLowerCase()}-${region.toUpperCase()}`; -} - -function normalizeBraveLanguageParams(params: { search_lang?: string; ui_lang?: string }): { - search_lang?: string; - ui_lang?: string; - invalidField?: "search_lang" | "ui_lang"; -} { - const rawSearchLang = params.search_lang?.trim() || undefined; - const rawUiLang = params.ui_lang?.trim() || undefined; - let searchLangCandidate = rawSearchLang; - let uiLangCandidate = rawUiLang; - - // Recover common LLM mix-up: locale in search_lang + short code in ui_lang. - if (normalizeBraveUiLang(rawSearchLang) && normalizeBraveSearchLang(rawUiLang)) { - searchLangCandidate = rawUiLang; - uiLangCandidate = rawSearchLang; - } - - const search_lang = normalizeBraveSearchLang(searchLangCandidate); - if (searchLangCandidate && !search_lang) { - return { invalidField: "search_lang" }; - } - - const ui_lang = normalizeBraveUiLang(uiLangCandidate); - if (uiLangCandidate && !ui_lang) { - return { invalidField: "ui_lang" }; - } - - return { search_lang, ui_lang }; -} - -/** - * Normalizes freshness shortcut to the provider's expected format. - * Accepts both Brave format (pd/pw/pm/py) and Perplexity format (day/week/month/year). - * For Brave, also accepts date ranges (YYYY-MM-DDtoYYYY-MM-DD). - */ -function normalizeFreshness( - value: string | undefined, - provider: (typeof SEARCH_PROVIDERS)[number], -): string | undefined { - if (!value) { - return undefined; - } - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - - const lower = trimmed.toLowerCase(); - - if (BRAVE_FRESHNESS_SHORTCUTS.has(lower)) { - return provider === "brave" ? lower : FRESHNESS_TO_RECENCY[lower]; - } - - if (PERPLEXITY_RECENCY_VALUES.has(lower)) { - return provider === "perplexity" ? lower : RECENCY_TO_FRESHNESS[lower]; - } - - // Brave date range support - if (provider === "brave") { - const match = trimmed.match(BRAVE_FRESHNESS_RANGE); - if (match) { - const [, start, end] = match; - if (isValidIsoDate(start) && isValidIsoDate(end) && start <= end) { - return `${start}to${end}`; - } - } - } - - return undefined; -} - -function isValidIsoDate(value: string): boolean { - if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { - return false; - } - const [year, month, day] = value.split("-").map((part) => Number.parseInt(part, 10)); - if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { - return false; - } - - const date = new Date(Date.UTC(year, month - 1, day)); - return ( - date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day - ); -} - -function resolveSiteName(url: string | undefined): string | undefined { - if (!url) { - return undefined; - } - try { - return new URL(url).hostname; - } catch { - return undefined; - } -} - -async function throwWebSearchApiError(res: Response, providerLabel: string): Promise { - const detailResult = await readResponseText(res, { maxBytes: 64_000 }); - const detail = detailResult.text; - throw new Error(`${providerLabel} API error (${res.status}): ${detail || res.statusText}`); -} - -async function runPerplexitySearchApi(params: { - query: string; - apiKey: string; - count: number; - timeoutSeconds: number; - country?: string; - searchDomainFilter?: string[]; - searchRecencyFilter?: string; - searchLanguageFilter?: string[]; - searchAfterDate?: string; - searchBeforeDate?: string; - maxTokens?: number; - maxTokensPerPage?: number; -}): Promise< - Array<{ title: string; url: string; description: string; published?: string; siteName?: string }> -> { - const body: Record = { - query: params.query, - max_results: params.count, - }; - - if (params.country) { - body.country = params.country; - } - if (params.searchDomainFilter && params.searchDomainFilter.length > 0) { - body.search_domain_filter = params.searchDomainFilter; - } - if (params.searchRecencyFilter) { - body.search_recency_filter = params.searchRecencyFilter; - } - if (params.searchLanguageFilter && params.searchLanguageFilter.length > 0) { - body.search_language_filter = params.searchLanguageFilter; - } - if (params.searchAfterDate) { - body.search_after_date = params.searchAfterDate; - } - if (params.searchBeforeDate) { - body.search_before_date = params.searchBeforeDate; - } - if (params.maxTokens !== undefined) { - body.max_tokens = params.maxTokens; - } - if (params.maxTokensPerPage !== undefined) { - body.max_tokens_per_page = params.maxTokensPerPage; - } - - return withTrustedWebSearchEndpoint( - { - url: PERPLEXITY_SEARCH_ENDPOINT, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - Authorization: `Bearer ${params.apiKey}`, - "HTTP-Referer": "https://openclaw.ai", - "X-Title": "OpenClaw Web Search", - }, - body: JSON.stringify(body), - }, - }, - async (res) => { - if (!res.ok) { - return await throwWebSearchApiError(res, "Perplexity Search"); - } - - const data = (await res.json()) as PerplexitySearchApiResponse; - const results = Array.isArray(data.results) ? data.results : []; - - return results.map((entry) => { - const title = entry.title ?? ""; - const url = entry.url ?? ""; - const snippet = entry.snippet ?? ""; - return { - title: title ? wrapWebContent(title, "web_search") : "", - url, - description: snippet ? wrapWebContent(snippet, "web_search") : "", - published: entry.date ?? undefined, - siteName: resolveSiteName(url) || undefined, - }; - }); - }, - ); -} - -async function runPerplexitySearch(params: { - query: string; - apiKey: string; - baseUrl: string; - model: string; - timeoutSeconds: number; - freshness?: string; -}): Promise<{ content: string; citations: string[] }> { - const baseUrl = params.baseUrl.trim().replace(/\/$/, ""); - const endpoint = `${baseUrl}/chat/completions`; - const model = resolvePerplexityRequestModel(baseUrl, params.model); - - const body: Record = { - model, - messages: [ - { - role: "user", - content: params.query, - }, - ], - }; - - if (params.freshness) { - body.search_recency_filter = params.freshness; - } - - return withTrustedWebSearchEndpoint( - { - url: endpoint, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${params.apiKey}`, - "HTTP-Referer": "https://openclaw.ai", - "X-Title": "OpenClaw Web Search", - }, - body: JSON.stringify(body), - }, - }, - async (res) => { - if (!res.ok) { - return await throwWebSearchApiError(res, "Perplexity"); - } - - const data = (await res.json()) as PerplexitySearchResponse; - const content = data.choices?.[0]?.message?.content ?? "No response"; - // Prefer top-level citations; fall back to OpenRouter-style message annotations. - const citations = extractPerplexityCitations(data); - - return { content, citations }; - }, - ); -} - -async function runGrokSearch(params: { - query: string; - apiKey: string; - model: string; - timeoutSeconds: number; - inlineCitations: boolean; -}): Promise<{ - content: string; - citations: string[]; - inlineCitations?: GrokSearchResponse["inline_citations"]; -}> { - const body: Record = { - model: params.model, - input: [ - { - role: "user", - content: params.query, - }, - ], - tools: [{ type: "web_search" }], - }; - - // Note: xAI's /v1/responses endpoint does not support the `include` - // parameter (returns 400 "Argument not supported: include"). Inline - // citations are returned automatically when available — we just parse - // them from the response without requesting them explicitly (#12910). - - return withTrustedWebSearchEndpoint( - { - url: XAI_API_ENDPOINT, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${params.apiKey}`, - }, - body: JSON.stringify(body), - }, - }, - async (res) => { - if (!res.ok) { - return await throwWebSearchApiError(res, "xAI"); - } - - const data = (await res.json()) as GrokSearchResponse; - const { text: extractedText, annotationCitations } = extractGrokContent(data); - const content = extractedText ?? "No response"; - // Prefer top-level citations; fall back to annotation-derived ones - const citations = (data.citations ?? []).length > 0 ? data.citations! : annotationCitations; - const inlineCitations = data.inline_citations; - - return { content, citations, inlineCitations }; - }, - ); -} - -function extractKimiMessageText(message: KimiMessage | undefined): string | undefined { - const content = message?.content?.trim(); - if (content) { - return content; - } - const reasoning = message?.reasoning_content?.trim(); - return reasoning || undefined; -} - -function extractKimiCitations(data: KimiSearchResponse): string[] { - const citations = (data.search_results ?? []) - .map((entry) => entry.url?.trim()) - .filter((url): url is string => Boolean(url)); - - for (const toolCall of data.choices?.[0]?.message?.tool_calls ?? []) { - const rawArguments = toolCall.function?.arguments; - if (!rawArguments) { - continue; - } - try { - const parsed = JSON.parse(rawArguments) as { - search_results?: Array<{ url?: string }>; - url?: string; - }; - if (typeof parsed.url === "string" && parsed.url.trim()) { - citations.push(parsed.url.trim()); - } - for (const result of parsed.search_results ?? []) { - if (typeof result.url === "string" && result.url.trim()) { - citations.push(result.url.trim()); - } - } - } catch { - // ignore malformed tool arguments - } - } - - return [...new Set(citations)]; -} - -function buildKimiToolResultContent(data: KimiSearchResponse): string { - return JSON.stringify({ - search_results: (data.search_results ?? []).map((entry) => ({ - title: entry.title ?? "", - url: entry.url ?? "", - content: entry.content ?? "", - })), - }); -} - -async function runKimiSearch(params: { - query: string; - apiKey: string; - baseUrl: string; - model: string; - timeoutSeconds: number; -}): Promise<{ content: string; citations: string[] }> { - const baseUrl = params.baseUrl.trim().replace(/\/$/, ""); - const endpoint = `${baseUrl}/chat/completions`; - const messages: Array> = [ - { - role: "user", - content: params.query, - }, - ]; - const collectedCitations = new Set(); - const MAX_ROUNDS = 3; - - for (let round = 0; round < MAX_ROUNDS; round += 1) { - const nextResult = await withTrustedWebSearchEndpoint( - { - url: endpoint, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${params.apiKey}`, - }, - body: JSON.stringify({ - model: params.model, - messages, - tools: [KIMI_WEB_SEARCH_TOOL], - }), - }, - }, - async ( - res, - ): Promise<{ done: true; content: string; citations: string[] } | { done: false }> => { - if (!res.ok) { - return await throwWebSearchApiError(res, "Kimi"); - } - - const data = (await res.json()) as KimiSearchResponse; - for (const citation of extractKimiCitations(data)) { - collectedCitations.add(citation); - } - const choice = data.choices?.[0]; - const message = choice?.message; - const text = extractKimiMessageText(message); - const toolCalls = message?.tool_calls ?? []; - - if (choice?.finish_reason !== "tool_calls" || toolCalls.length === 0) { - return { done: true, content: text ?? "No response", citations: [...collectedCitations] }; - } - - messages.push({ - role: "assistant", - content: message?.content ?? "", - ...(message?.reasoning_content - ? { - reasoning_content: message.reasoning_content, - } - : {}), - tool_calls: toolCalls, - }); - - const toolContent = buildKimiToolResultContent(data); - let pushedToolResult = false; - for (const toolCall of toolCalls) { - const toolCallId = toolCall.id?.trim(); - if (!toolCallId) { - continue; - } - pushedToolResult = true; - messages.push({ - role: "tool", - tool_call_id: toolCallId, - content: toolContent, - }); - } - - if (!pushedToolResult) { - return { done: true, content: text ?? "No response", citations: [...collectedCitations] }; - } - - return { done: false }; - }, - ); - - if (nextResult.done) { - return { content: nextResult.content, citations: nextResult.citations }; - } - } - - return { - content: "Search completed but no final answer was produced.", - citations: [...collectedCitations], - }; -} - -function mapBraveLlmContextResults( - data: BraveLlmContextResponse, -): { url: string; title: string; snippets: string[]; siteName?: string }[] { - const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : []; - return genericResults.map((entry) => ({ - url: entry.url ?? "", - title: entry.title ?? "", - snippets: (entry.snippets ?? []).filter((s) => typeof s === "string" && s.length > 0), - siteName: resolveSiteName(entry.url) || undefined, - })); -} - -async function runBraveLlmContextSearch(params: { - query: string; - apiKey: string; - timeoutSeconds: number; - country?: string; - search_lang?: string; - freshness?: string; -}): Promise<{ - results: Array<{ - url: string; - title: string; - snippets: string[]; - siteName?: string; - }>; - sources?: BraveLlmContextResponse["sources"]; -}> { - const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT); - url.searchParams.set("q", params.query); - if (params.country) { - url.searchParams.set("country", params.country); - } - if (params.search_lang) { - url.searchParams.set("search_lang", params.search_lang); - } - if (params.freshness) { - url.searchParams.set("freshness", params.freshness); - } - - return withTrustedWebSearchEndpoint( - { - url: url.toString(), - timeoutSeconds: params.timeoutSeconds, - init: { - method: "GET", - headers: { - Accept: "application/json", - "X-Subscription-Token": params.apiKey, - }, - }, - }, - async (res) => { - if (!res.ok) { - const detailResult = await readResponseText(res, { maxBytes: 64_000 }); - const detail = detailResult.text; - throw new Error(`Brave LLM Context API error (${res.status}): ${detail || res.statusText}`); - } - - const data = (await res.json()) as BraveLlmContextResponse; - const mapped = mapBraveLlmContextResults(data); - - return { results: mapped, sources: data.sources }; - }, - ); -} - -async function runWebSearch(params: { - query: string; - count: number; - apiKey: string; - timeoutSeconds: number; - cacheTtlMs: number; - provider: (typeof SEARCH_PROVIDERS)[number]; - country?: string; - language?: string; - search_lang?: string; - ui_lang?: string; - freshness?: string; - dateAfter?: string; - dateBefore?: string; - searchDomainFilter?: string[]; - maxTokens?: number; - maxTokensPerPage?: number; - perplexityBaseUrl?: string; - perplexityModel?: string; - perplexityTransport?: PerplexityTransport; - grokModel?: string; - grokInlineCitations?: boolean; - geminiModel?: string; - kimiBaseUrl?: string; - kimiModel?: string; - braveMode?: "web" | "llm-context"; -}): Promise> { - const effectiveBraveMode = params.braveMode ?? "web"; - const providerSpecificKey = - params.provider === "perplexity" - ? `${params.perplexityTransport ?? "search_api"}:${params.perplexityBaseUrl ?? PERPLEXITY_DIRECT_BASE_URL}:${params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL}` - : params.provider === "grok" - ? `${params.grokModel ?? DEFAULT_GROK_MODEL}:${String(params.grokInlineCitations ?? false)}` - : params.provider === "gemini" - ? (params.geminiModel ?? DEFAULT_GEMINI_MODEL) - : params.provider === "kimi" - ? `${params.kimiBaseUrl ?? DEFAULT_KIMI_BASE_URL}:${params.kimiModel ?? DEFAULT_KIMI_MODEL}` - : ""; - const cacheKey = normalizeCacheKey( - params.provider === "brave" && effectiveBraveMode === "llm-context" - ? `${params.provider}:llm-context:${params.query}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.freshness || "default"}` - : `${params.provider}:${effectiveBraveMode}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}:${params.dateAfter || "default"}:${params.dateBefore || "default"}:${params.searchDomainFilter?.join(",") || "default"}:${params.maxTokens || "default"}:${params.maxTokensPerPage || "default"}:${providerSpecificKey}`, - ); - const cached = readCache(SEARCH_CACHE, cacheKey); - if (cached) { - return { ...cached.value, cached: true }; - } - - const start = Date.now(); - - if (params.provider === "perplexity") { - if (params.perplexityTransport === "chat_completions") { - const { content, citations } = await runPerplexitySearch({ - query: params.query, - apiKey: params.apiKey, - baseUrl: params.perplexityBaseUrl ?? DEFAULT_PERPLEXITY_BASE_URL, - model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL, - timeoutSeconds: params.timeoutSeconds, - freshness: params.freshness, - }); - - const payload = { - query: params.query, - provider: params.provider, - model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: params.provider, - wrapped: true, - }, - content: wrapWebContent(content, "web_search"), - citations, - }; - writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); - return payload; - } - - const results = await runPerplexitySearchApi({ - query: params.query, - apiKey: params.apiKey, - count: params.count, - timeoutSeconds: params.timeoutSeconds, - country: params.country, - searchDomainFilter: params.searchDomainFilter, - searchRecencyFilter: params.freshness, - searchLanguageFilter: params.language ? [params.language] : undefined, - searchAfterDate: params.dateAfter ? isoToPerplexityDate(params.dateAfter) : undefined, - searchBeforeDate: params.dateBefore ? isoToPerplexityDate(params.dateBefore) : undefined, - maxTokens: params.maxTokens, - maxTokensPerPage: params.maxTokensPerPage, - }); - - const payload = { - query: params.query, - provider: params.provider, - count: results.length, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: params.provider, - wrapped: true, - }, - results, - }; - writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); - return payload; - } - - if (params.provider === "grok") { - const { content, citations, inlineCitations } = await runGrokSearch({ - query: params.query, - apiKey: params.apiKey, - model: params.grokModel ?? DEFAULT_GROK_MODEL, - timeoutSeconds: params.timeoutSeconds, - inlineCitations: params.grokInlineCitations ?? false, - }); - - const payload = { - query: params.query, - provider: params.provider, - model: params.grokModel ?? DEFAULT_GROK_MODEL, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: params.provider, - wrapped: true, - }, - content: wrapWebContent(content), - citations, - inlineCitations, - }; - writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); - return payload; - } - - if (params.provider === "kimi") { - const { content, citations } = await runKimiSearch({ - query: params.query, - apiKey: params.apiKey, - baseUrl: params.kimiBaseUrl ?? DEFAULT_KIMI_BASE_URL, - model: params.kimiModel ?? DEFAULT_KIMI_MODEL, - timeoutSeconds: params.timeoutSeconds, - }); - - const payload = { - query: params.query, - provider: params.provider, - model: params.kimiModel ?? DEFAULT_KIMI_MODEL, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: params.provider, - wrapped: true, - }, - content: wrapWebContent(content), - citations, - }; - writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); - return payload; - } - - if (params.provider === "gemini") { - const geminiResult = await runGeminiSearch({ - query: params.query, - apiKey: params.apiKey, - model: params.geminiModel ?? DEFAULT_GEMINI_MODEL, - timeoutSeconds: params.timeoutSeconds, - }); - - const payload = { - query: params.query, - provider: params.provider, - model: params.geminiModel ?? DEFAULT_GEMINI_MODEL, - tookMs: Date.now() - start, // Includes redirect URL resolution time - externalContent: { - untrusted: true, - source: "web_search", - provider: params.provider, - wrapped: true, - }, - content: wrapWebContent(geminiResult.content), - citations: geminiResult.citations, - }; - writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); - return payload; - } - - if (params.provider !== "brave") { - throw new Error("Unsupported web search provider."); - } - - if (effectiveBraveMode === "llm-context") { - const { results: llmResults, sources } = await runBraveLlmContextSearch({ - query: params.query, - apiKey: params.apiKey, - timeoutSeconds: params.timeoutSeconds, - country: params.country, - search_lang: params.search_lang, - freshness: params.freshness, - }); - - const mapped = llmResults.map((entry) => ({ - title: entry.title ? wrapWebContent(entry.title, "web_search") : "", - url: entry.url, - snippets: entry.snippets.map((s) => wrapWebContent(s, "web_search")), - siteName: entry.siteName, - })); - - const payload = { - query: params.query, - provider: params.provider, - mode: "llm-context" as const, - count: mapped.length, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: params.provider, - wrapped: true, - }, - results: mapped, - sources, - }; - writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); - return payload; - } - - const url = new URL(BRAVE_SEARCH_ENDPOINT); - url.searchParams.set("q", params.query); - url.searchParams.set("count", String(params.count)); - if (params.country) { - url.searchParams.set("country", params.country); - } - if (params.search_lang || params.language) { - url.searchParams.set("search_lang", (params.search_lang || params.language)!); - } - if (params.ui_lang) { - url.searchParams.set("ui_lang", params.ui_lang); - } - if (params.freshness) { - url.searchParams.set("freshness", params.freshness); - } else if (params.dateAfter && params.dateBefore) { - url.searchParams.set("freshness", `${params.dateAfter}to${params.dateBefore}`); - } else if (params.dateAfter) { - url.searchParams.set( - "freshness", - `${params.dateAfter}to${new Date().toISOString().slice(0, 10)}`, - ); - } else if (params.dateBefore) { - url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`); - } - - const mapped = await withTrustedWebSearchEndpoint( - { - url: url.toString(), - timeoutSeconds: params.timeoutSeconds, - init: { - method: "GET", - headers: { - Accept: "application/json", - "X-Subscription-Token": params.apiKey, - }, - }, - }, - async (res) => { - if (!res.ok) { - const detailResult = await readResponseText(res, { maxBytes: 64_000 }); - const detail = detailResult.text; - throw new Error(`Brave Search API error (${res.status}): ${detail || res.statusText}`); - } - - const data = (await res.json()) as BraveSearchResponse; - const results = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : []; - return results.map((entry) => { - const description = entry.description ?? ""; - const title = entry.title ?? ""; - const url = entry.url ?? ""; - const rawSiteName = resolveSiteName(url); - return { - title: title ? wrapWebContent(title, "web_search") : "", - url, // Keep raw for tool chaining - description: description ? wrapWebContent(description, "web_search") : "", - published: entry.age || undefined, - siteName: rawSiteName || undefined, - }; - }); - }, - ); - - const payload = { - query: params.query, - provider: params.provider, - count: mapped.length, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: params.provider, - wrapped: true, - }, - results: mapped, - }; - writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); - return payload; + return providers[0]?.id ?? "brave"; } export function createWebSearchTool(options?: { @@ -1898,325 +104,45 @@ export function createWebSearchTool(options?: { return null; } - const provider = + const providers = resolvePluginWebSearchProviders({ + config: options?.config, + bundledAllowlistCompat: true, + }); + if (providers.length === 0) { + return null; + } + + const providerId = options?.runtimeWebSearch?.selectedProvider ?? options?.runtimeWebSearch?.providerConfigured ?? resolveSearchProvider(search); - const perplexityConfig = resolvePerplexityConfig(search); - const perplexitySchemaTransportHint = - options?.runtimeWebSearch?.perplexityTransport ?? - resolvePerplexitySchemaTransportHint(perplexityConfig); - const grokConfig = resolveGrokConfig(search); - const geminiConfig = resolveGeminiConfig(search); - const kimiConfig = resolveKimiConfig(search); - const braveConfig = resolveBraveConfig(search); - const braveMode = resolveBraveMode(braveConfig); + const provider = + providers.find((entry) => entry.id === providerId) ?? + providers.find((entry) => entry.id === resolveSearchProvider(search)) ?? + providers[0]; + if (!provider) { + return null; + } - const description = - provider === "perplexity" - ? perplexitySchemaTransportHint === "chat_completions" - ? "Search the web using Perplexity Sonar via Perplexity/OpenRouter chat completions. Returns AI-synthesized answers with citations from web-grounded search." - : "Search the web using Perplexity. Runtime routing decides between native Search API and Sonar chat-completions compatibility. Structured filters are available on the native Search API path." - : provider === "grok" - ? "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search." - : provider === "kimi" - ? "Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search." - : provider === "gemini" - ? "Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search." - : braveMode === "llm-context" - ? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding." - : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research."; + const definition = provider.createTool({ + config: options?.config, + searchConfig: search as Record | undefined, + runtimeMetadata: options?.runtimeWebSearch, + }); + if (!definition) { + return null; + } return { label: "Web Search", name: "web_search", - description, - parameters: createWebSearchSchema({ - provider, - perplexityTransport: provider === "perplexity" ? perplexitySchemaTransportHint : undefined, - }), - execute: async (_toolCallId, args) => { - // Resolve Perplexity auth/transport lazily at execution time so unrelated providers - // do not touch Perplexity-only credential surfaces during tool construction. - const perplexityRuntime = - provider === "perplexity" ? resolvePerplexityTransport(perplexityConfig) : undefined; - const apiKey = - provider === "perplexity" - ? perplexityRuntime?.apiKey - : provider === "grok" - ? resolveGrokApiKey(grokConfig) - : provider === "kimi" - ? resolveKimiApiKey(kimiConfig) - : provider === "gemini" - ? resolveGeminiApiKey(geminiConfig) - : resolveSearchApiKey(search); - - if (!apiKey) { - return jsonResult(missingSearchKeyPayload(provider)); - } - - const supportsStructuredPerplexityFilters = - provider === "perplexity" && perplexityRuntime?.transport === "search_api"; - const params = args as Record; - const query = readStringParam(params, "query", { required: true }); - const count = - readNumberParam(params, "count", { integer: true }) ?? search?.maxResults ?? undefined; - const country = readStringParam(params, "country"); - if ( - country && - provider !== "brave" && - !(provider === "perplexity" && supportsStructuredPerplexityFilters) - ) { - return jsonResult({ - error: "unsupported_country", - message: - provider === "perplexity" - ? "country filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it." - : `country filtering is not supported by the ${provider} provider. Only Brave and Perplexity support country filtering.`, - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const language = readStringParam(params, "language"); - if ( - language && - provider !== "brave" && - !(provider === "perplexity" && supportsStructuredPerplexityFilters) - ) { - return jsonResult({ - error: "unsupported_language", - message: - provider === "perplexity" - ? "language filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it." - : `language filtering is not supported by the ${provider} provider. Only Brave and Perplexity support language filtering.`, - docs: "https://docs.openclaw.ai/tools/web", - }); - } - if (language && provider === "perplexity" && !/^[a-z]{2}$/i.test(language)) { - return jsonResult({ - error: "invalid_language", - message: "language must be a 2-letter ISO 639-1 code like 'en', 'de', or 'fr'.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const search_lang = readStringParam(params, "search_lang"); - const ui_lang = readStringParam(params, "ui_lang"); - // For Brave, accept both `language` (unified) and `search_lang` - const normalizedBraveLanguageParams = - provider === "brave" - ? normalizeBraveLanguageParams({ search_lang: search_lang || language, ui_lang }) - : { search_lang: language, ui_lang }; - if (normalizedBraveLanguageParams.invalidField === "search_lang") { - return jsonResult({ - error: "invalid_search_lang", - message: - "search_lang must be a Brave-supported language code like 'en', 'en-gb', 'zh-hans', or 'zh-hant'.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - if (normalizedBraveLanguageParams.invalidField === "ui_lang") { - return jsonResult({ - error: "invalid_ui_lang", - message: "ui_lang must be a language-region locale like 'en-US'.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const resolvedSearchLang = normalizedBraveLanguageParams.search_lang; - const resolvedUiLang = normalizedBraveLanguageParams.ui_lang; - if (resolvedUiLang && provider === "brave" && braveMode === "llm-context") { - return jsonResult({ - error: "unsupported_ui_lang", - message: - "ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const rawFreshness = readStringParam(params, "freshness"); - if (rawFreshness && provider !== "brave" && provider !== "perplexity") { - return jsonResult({ - error: "unsupported_freshness", - message: `freshness filtering is not supported by the ${provider} provider. Only Brave and Perplexity support freshness.`, - docs: "https://docs.openclaw.ai/tools/web", - }); - } - if (rawFreshness && provider === "brave" && braveMode === "llm-context") { - return jsonResult({ - error: "unsupported_freshness", - message: - "freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const freshness = rawFreshness ? normalizeFreshness(rawFreshness, provider) : undefined; - if (rawFreshness && !freshness) { - return jsonResult({ - error: "invalid_freshness", - message: "freshness must be day, week, month, or year.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const rawDateAfter = readStringParam(params, "date_after"); - const rawDateBefore = readStringParam(params, "date_before"); - if (rawFreshness && (rawDateAfter || rawDateBefore)) { - return jsonResult({ - error: "conflicting_time_filters", - message: - "freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - if ( - (rawDateAfter || rawDateBefore) && - provider !== "brave" && - !(provider === "perplexity" && supportsStructuredPerplexityFilters) - ) { - return jsonResult({ - error: "unsupported_date_filter", - message: - provider === "perplexity" - ? "date_after/date_before are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them." - : `date_after/date_before filtering is not supported by the ${provider} provider. Only Brave and Perplexity support date filtering.`, - docs: "https://docs.openclaw.ai/tools/web", - }); - } - if ((rawDateAfter || rawDateBefore) && provider === "brave" && braveMode === "llm-context") { - return jsonResult({ - error: "unsupported_date_filter", - message: - "date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined; - if (rawDateAfter && !dateAfter) { - return jsonResult({ - error: "invalid_date", - message: "date_after must be YYYY-MM-DD format.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined; - if (rawDateBefore && !dateBefore) { - return jsonResult({ - error: "invalid_date", - message: "date_before must be YYYY-MM-DD format.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - if (dateAfter && dateBefore && dateAfter > dateBefore) { - return jsonResult({ - error: "invalid_date_range", - message: "date_after must be before date_before.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const domainFilter = readStringArrayParam(params, "domain_filter"); - if ( - domainFilter && - domainFilter.length > 0 && - !(provider === "perplexity" && supportsStructuredPerplexityFilters) - ) { - return jsonResult({ - error: "unsupported_domain_filter", - message: - provider === "perplexity" - ? "domain_filter is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it." - : `domain_filter is not supported by the ${provider} provider. Only Perplexity supports domain filtering.`, - docs: "https://docs.openclaw.ai/tools/web", - }); - } - - if (domainFilter && domainFilter.length > 0) { - const hasDenylist = domainFilter.some((d) => d.startsWith("-")); - const hasAllowlist = domainFilter.some((d) => !d.startsWith("-")); - if (hasDenylist && hasAllowlist) { - return jsonResult({ - error: "invalid_domain_filter", - message: - "domain_filter cannot mix allowlist and denylist entries. Use either all positive entries (allowlist) or all entries prefixed with '-' (denylist).", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - if (domainFilter.length > 20) { - return jsonResult({ - error: "invalid_domain_filter", - message: "domain_filter supports a maximum of 20 domains.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - } - - const maxTokens = readNumberParam(params, "max_tokens", { integer: true }); - const maxTokensPerPage = readNumberParam(params, "max_tokens_per_page", { integer: true }); - if ( - provider === "perplexity" && - perplexityRuntime?.transport === "chat_completions" && - (maxTokens !== undefined || maxTokensPerPage !== undefined) - ) { - return jsonResult({ - error: "unsupported_content_budget", - message: - "max_tokens and max_tokens_per_page are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - - const result = await runWebSearch({ - query, - count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT), - apiKey, - timeoutSeconds: resolveTimeoutSeconds(search?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS), - cacheTtlMs: resolveCacheTtlMs(search?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES), - provider, - country, - language, - search_lang: resolvedSearchLang, - ui_lang: resolvedUiLang, - freshness, - dateAfter, - dateBefore, - searchDomainFilter: domainFilter, - maxTokens: maxTokens ?? undefined, - maxTokensPerPage: maxTokensPerPage ?? undefined, - perplexityBaseUrl: perplexityRuntime?.baseUrl, - perplexityModel: perplexityRuntime?.model, - perplexityTransport: perplexityRuntime?.transport, - grokModel: resolveGrokModel(grokConfig), - grokInlineCitations: resolveGrokInlineCitations(grokConfig), - geminiModel: resolveGeminiModel(geminiConfig), - kimiBaseUrl: resolveKimiBaseUrl(kimiConfig), - kimiModel: resolveKimiModel(kimiConfig), - braveMode, - }); - return jsonResult(result); - }, + description: definition.description, + parameters: definition.parameters, + execute: async (_toolCallId, args) => jsonResult(await definition.execute(args)), }; } export const __testing = { + ...coreTesting, resolveSearchProvider, - inferPerplexityBaseUrlFromApiKey, - resolvePerplexityBaseUrl, - resolvePerplexityModel, - resolvePerplexityTransport, - isDirectPerplexityBaseUrl, - resolvePerplexityRequestModel, - resolvePerplexityApiKey, - normalizeBraveLanguageParams, - normalizeFreshness, - normalizeToIsoDate, - isoToPerplexityDate, - SEARCH_CACHE, - FRESHNESS_TO_RECENCY, - RECENCY_TO_FRESHNESS, - resolveGrokApiKey, - resolveGrokModel, - resolveGrokInlineCitations, - extractGrokContent, - resolveKimiApiKey, - resolveKimiModel, - resolveKimiBaseUrl, - extractKimiCitations, - resolveRedirectUrl: resolveCitationRedirectUrl, - resolveBraveMode, - mapBraveLlmContextResults, -} as const; +}; diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index 10e2df9f81b..93451a9d6e9 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -244,18 +244,66 @@ describe("setupSearch", () => { }); it("stores env-backed SecretRef when secretInputMode=ref for perplexity", async () => { + const originalPerplexity = process.env.PERPLEXITY_API_KEY; + const originalOpenRouter = process.env.OPENROUTER_API_KEY; + delete process.env.PERPLEXITY_API_KEY; + delete process.env.OPENROUTER_API_KEY; const cfg: OpenClawConfig = {}; - const { prompter } = createPrompter({ selectValue: "perplexity" }); - const result = await setupSearch(cfg, runtime, prompter, { - secretInputMode: "ref", // pragma: allowlist secret - }); - expect(result.tools?.web?.search?.provider).toBe("perplexity"); - expect(result.tools?.web?.search?.perplexity?.apiKey).toEqual({ - source: "env", - provider: "default", - id: "PERPLEXITY_API_KEY", // pragma: allowlist secret - }); - expect(prompter.text).not.toHaveBeenCalled(); + try { + const { prompter } = createPrompter({ selectValue: "perplexity" }); + const result = await setupSearch(cfg, runtime, prompter, { + secretInputMode: "ref", // pragma: allowlist secret + }); + expect(result.tools?.web?.search?.provider).toBe("perplexity"); + expect(result.tools?.web?.search?.perplexity?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "PERPLEXITY_API_KEY", // pragma: allowlist secret + }); + expect(prompter.text).not.toHaveBeenCalled(); + } finally { + if (originalPerplexity === undefined) { + delete process.env.PERPLEXITY_API_KEY; + } else { + process.env.PERPLEXITY_API_KEY = originalPerplexity; + } + if (originalOpenRouter === undefined) { + delete process.env.OPENROUTER_API_KEY; + } else { + process.env.OPENROUTER_API_KEY = originalOpenRouter; + } + } + }); + + it("prefers detected OPENROUTER_API_KEY SecretRef for perplexity ref mode", async () => { + const originalPerplexity = process.env.PERPLEXITY_API_KEY; + const originalOpenRouter = process.env.OPENROUTER_API_KEY; + delete process.env.PERPLEXITY_API_KEY; + process.env.OPENROUTER_API_KEY = "sk-or-test"; + const cfg: OpenClawConfig = {}; + try { + const { prompter } = createPrompter({ selectValue: "perplexity" }); + const result = await setupSearch(cfg, runtime, prompter, { + secretInputMode: "ref", // pragma: allowlist secret + }); + expect(result.tools?.web?.search?.perplexity?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "OPENROUTER_API_KEY", // pragma: allowlist secret + }); + expect(prompter.text).not.toHaveBeenCalled(); + } finally { + if (originalPerplexity === undefined) { + delete process.env.PERPLEXITY_API_KEY; + } else { + process.env.PERPLEXITY_API_KEY = originalPerplexity; + } + if (originalOpenRouter === undefined) { + delete process.env.OPENROUTER_API_KEY; + } else { + process.env.OPENROUTER_API_KEY = originalOpenRouter; + } + } }); it("stores env-backed SecretRef when secretInputMode=ref for brave", async () => { diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index df2f4643b60..d1281fe3fc7 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -6,11 +6,12 @@ import { hasConfiguredSecretInput, normalizeSecretInputString, } from "../config/types.secrets.js"; +import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { SecretInputMode } from "./onboard-types.js"; -export type SearchProvider = "brave" | "gemini" | "grok" | "kimi" | "perplexity"; +export type SearchProvider = string; type SearchProviderEntry = { value: SearchProvider; @@ -21,48 +22,17 @@ type SearchProviderEntry = { signupUrl: string; }; -export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] = [ - { - value: "brave", - label: "Brave Search", - hint: "Structured results · country/language/time filters", - envKeys: ["BRAVE_API_KEY"], - placeholder: "BSA...", - signupUrl: "https://brave.com/search/api/", - }, - { - value: "gemini", - label: "Gemini (Google Search)", - hint: "Google Search grounding · AI-synthesized", - envKeys: ["GEMINI_API_KEY"], - placeholder: "AIza...", - signupUrl: "https://aistudio.google.com/apikey", - }, - { - value: "grok", - label: "Grok (xAI)", - hint: "xAI web-grounded responses", - envKeys: ["XAI_API_KEY"], - placeholder: "xai-...", - signupUrl: "https://console.x.ai/", - }, - { - value: "kimi", - label: "Kimi (Moonshot)", - hint: "Moonshot web search", - envKeys: ["KIMI_API_KEY", "MOONSHOT_API_KEY"], - placeholder: "sk-...", - signupUrl: "https://platform.moonshot.cn/", - }, - { - value: "perplexity", - label: "Perplexity Search", - hint: "Structured results · domain/country/language/time filters", - envKeys: ["PERPLEXITY_API_KEY"], - placeholder: "pplx-...", - signupUrl: "https://www.perplexity.ai/settings/api", - }, -] as const; +export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] = + resolvePluginWebSearchProviders({ + bundledAllowlistCompat: true, + }).map((provider) => ({ + value: provider.id, + label: provider.label, + hint: provider.hint, + envKeys: provider.envVars, + placeholder: provider.placeholder, + signupUrl: provider.signupUrl, + })); export function hasKeyInEnv(entry: SearchProviderEntry): boolean { return entry.envKeys.some((k) => Boolean(process.env[k]?.trim())); @@ -70,18 +40,11 @@ export function hasKeyInEnv(entry: SearchProviderEntry): boolean { function rawKeyValue(config: OpenClawConfig, provider: SearchProvider): unknown { const search = config.tools?.web?.search; - switch (provider) { - case "brave": - return search?.apiKey; - case "gemini": - return search?.gemini?.apiKey; - case "grok": - return search?.grok?.apiKey; - case "kimi": - return search?.kimi?.apiKey; - case "perplexity": - return search?.perplexity?.apiKey; - } + const entry = resolvePluginWebSearchProviders({ + config, + bundledAllowlistCompat: true, + }).find((candidate) => candidate.id === provider); + return entry?.getCredentialValue(search as Record | undefined); } /** Returns the plaintext key string, or undefined for SecretRefs/missing. */ @@ -128,22 +91,12 @@ export function applySearchKey( key: SecretInput, ): OpenClawConfig { const search = { ...config.tools?.web?.search, provider, enabled: true }; - switch (provider) { - case "brave": - search.apiKey = key; - break; - case "gemini": - search.gemini = { ...search.gemini, apiKey: key }; - break; - case "grok": - search.grok = { ...search.grok, apiKey: key }; - break; - case "kimi": - search.kimi = { ...search.kimi, apiKey: key }; - break; - case "perplexity": - search.perplexity = { ...search.perplexity, apiKey: key }; - break; + const entry = resolvePluginWebSearchProviders({ + config, + bundledAllowlistCompat: true, + }).find((candidate) => candidate.id === provider); + if (entry) { + entry.setCredentialValue(search as Record, key); } return { ...config, @@ -225,7 +178,7 @@ export async function setupSearch( return SEARCH_PROVIDER_OPTIONS[0].value; })(); - type PickerValue = SearchProvider | "__skip__"; + type PickerValue = string; const choice = await prompter.select({ message: "Search provider", options: [ @@ -236,7 +189,7 @@ export async function setupSearch( hint: "Configure later with openclaw configure --section web", }, ], - initialValue: defaultProvider as PickerValue, + initialValue: defaultProvider, }); if (choice === "__skip__") { diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index 7ddb4ca3ab4..9df692962f2 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -6,6 +6,40 @@ vi.mock("../runtime.js", () => ({ defaultRuntime: { log: vi.fn(), error: vi.fn() }, })); +vi.mock("../plugins/web-search-providers.js", () => { + const getScoped = (key: string) => (search?: Record) => + (search?.[key] as { apiKey?: unknown } | undefined)?.apiKey; + return { + resolvePluginWebSearchProviders: () => [ + { + id: "brave", + envVars: ["BRAVE_API_KEY"], + getCredentialValue: (search?: Record) => search?.apiKey, + }, + { + id: "gemini", + envVars: ["GEMINI_API_KEY"], + getCredentialValue: getScoped("gemini"), + }, + { + id: "grok", + envVars: ["XAI_API_KEY"], + getCredentialValue: getScoped("grok"), + }, + { + id: "kimi", + envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"], + getCredentialValue: getScoped("kimi"), + }, + { + id: "perplexity", + envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"], + getCredentialValue: getScoped("perplexity"), + }, + ], + }; +}); + const { __testing } = await import("../agents/tools/web-search.js"); const { resolveSearchProvider } = __testing; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 6f32ee0d151..13f6842d1e1 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -359,6 +359,7 @@ function createPluginRecord(params: { hookNames: [], channelIds: [], providerIds: [], + webSearchProviderIds: [], gatewayMethods: [], cliCommands: [], services: [], diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 8e04106dc9c..42e9c236909 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -47,6 +47,7 @@ import type { PluginHookName, PluginHookHandlerMap, PluginHookRegistration as TypedPluginHookRegistration, + WebSearchProviderPlugin, } from "./types.js"; export type PluginToolRegistration = { @@ -103,6 +104,14 @@ export type PluginProviderRegistration = { rootDir?: string; }; +export type PluginWebSearchProviderRegistration = { + pluginId: string; + pluginName?: string; + provider: WebSearchProviderPlugin; + source: string; + rootDir?: string; +}; + export type PluginHookRegistration = { pluginId: string; entry: HookEntry; @@ -147,6 +156,7 @@ export type PluginRecord = { hookNames: string[]; channelIds: string[]; providerIds: string[]; + webSearchProviderIds: string[]; gatewayMethods: string[]; cliCommands: string[]; services: string[]; @@ -166,6 +176,7 @@ export type PluginRegistry = { channels: PluginChannelRegistration[]; channelSetups: PluginChannelSetupRegistration[]; providers: PluginProviderRegistration[]; + webSearchProviders: PluginWebSearchProviderRegistration[]; gatewayHandlers: GatewayRequestHandlers; httpRoutes: PluginHttpRouteRegistration[]; cliRegistrars: PluginCliRegistration[]; @@ -210,6 +221,7 @@ export function createEmptyPluginRegistry(): PluginRegistry { channels: [], channelSetups: [], providers: [], + webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], cliRegistrars: [], @@ -541,6 +553,37 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); }; + const registerWebSearchProvider = (record: PluginRecord, provider: WebSearchProviderPlugin) => { + const id = provider.id.trim(); + if (!id) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "web search provider registration missing id", + }); + return; + } + const existing = registry.webSearchProviders.find((entry) => entry.provider.id === id); + if (existing) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `web search provider already registered: ${id} (${existing.pluginId})`, + }); + return; + } + record.webSearchProviderIds.push(id); + registry.webSearchProviders.push({ + pluginId: record.id, + pluginName: record.name, + provider, + source: record.source, + rootDir: record.rootDir, + }); + }; + const registerCli = ( record: PluginRecord, registrar: OpenClawPluginCliRegistrar, @@ -749,6 +792,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerChannel: (registration) => registerChannel(record, registration, registrationMode), registerProvider: registrationMode === "full" ? (provider) => registerProvider(record, provider) : () => {}, + registerWebSearchProvider: + registrationMode === "full" + ? (provider) => registerWebSearchProvider(record, provider) + : () => {}, registerGatewayMethod: registrationMode === "full" ? (method, handler) => registerGatewayMethod(record, method, handler) @@ -818,6 +865,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerTool, registerChannel, registerProvider, + registerWebSearchProvider, registerGatewayMethod, registerCli, registerService, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 09a706a51ea..d96a8c65d8d 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -25,6 +25,7 @@ import type { InternalHookHandler } from "../hooks/internal-hooks.js"; import type { HookEntry } from "../hooks/types.js"; import type { ProviderUsageSnapshot } from "../infra/provider-usage.types.js"; import type { RuntimeEnv } from "../runtime.js"; +import type { RuntimeWebSearchMetadata } from "../secrets/runtime-web-tools.types.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { PluginRuntime } from "./runtime/types.js"; @@ -565,6 +566,34 @@ export type ProviderPlugin = { onModelSelected?: (ctx: ProviderModelSelectedContext) => Promise; }; +export type WebSearchProviderId = string; + +export type WebSearchProviderToolDefinition = { + description: string; + parameters: Record; + execute: (args: Record) => Promise>; +}; + +export type WebSearchProviderContext = { + config?: OpenClawConfig; + searchConfig?: Record; + runtimeMetadata?: RuntimeWebSearchMetadata; +}; + +export type WebSearchProviderPlugin = { + id: WebSearchProviderId; + label: string; + hint: string; + envVars: string[]; + placeholder: string; + signupUrl: string; + docsUrl?: string; + autoDetectOrder?: number; + getCredentialValue: (searchConfig?: Record) => unknown; + setCredentialValue: (searchConfigTarget: Record, value: unknown) => void; + createTool: (ctx: WebSearchProviderContext) => WebSearchProviderToolDefinition | null; +}; + export type OpenClawPluginGatewayMethod = { method: string; handler: GatewayRequestHandler; @@ -868,6 +897,7 @@ export type OpenClawPluginApi = { registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void; registerService: (service: OpenClawPluginService) => void; registerProvider: (provider: ProviderPlugin) => void; + registerWebSearchProvider: (provider: WebSearchProviderPlugin) => void; registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => void; /** * Register a custom command that bypasses the LLM agent. diff --git a/src/plugins/web-search-providers.test.ts b/src/plugins/web-search-providers.test.ts new file mode 100644 index 00000000000..af794d075c9 --- /dev/null +++ b/src/plugins/web-search-providers.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolvePluginWebSearchProviders } from "./web-search-providers.js"; + +const loadOpenClawPluginsMock = vi.fn(); + +vi.mock("./loader.js", () => ({ + loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args), +})); + +describe("resolvePluginWebSearchProviders", () => { + beforeEach(() => { + loadOpenClawPluginsMock.mockReset(); + loadOpenClawPluginsMock.mockReturnValue({ + webSearchProviders: [ + { + pluginId: "web-search-gemini", + provider: { + id: "gemini", + label: "Gemini", + hint: "hint", + envVars: ["GEMINI_API_KEY"], + placeholder: "AIza...", + signupUrl: "https://example.com", + autoDetectOrder: 20, + }, + }, + { + pluginId: "web-search-brave", + provider: { + id: "brave", + label: "Brave", + hint: "hint", + envVars: ["BRAVE_API_KEY"], + placeholder: "BSA...", + signupUrl: "https://example.com", + autoDetectOrder: 10, + }, + }, + ], + }); + }); + + it("forwards an explicit env to plugin loading", () => { + const env = { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv; + + const providers = resolvePluginWebSearchProviders({ + workspaceDir: "/workspace/explicit", + env, + }); + + expect(providers.map((provider) => provider.id)).toEqual(["brave", "gemini"]); + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceDir: "/workspace/explicit", + env, + }), + ); + }); + + it("can augment restrictive allowlists for bundled compatibility", () => { + resolvePluginWebSearchProviders({ + config: { + plugins: { + allow: ["openrouter"], + }, + }, + bundledAllowlistCompat: true, + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + plugins: expect.objectContaining({ + allow: expect.arrayContaining([ + "openrouter", + "web-search-brave", + "web-search-perplexity", + ]), + }), + }), + }), + ); + }); + + it("auto-enables bundled web search provider plugins when entries are missing", () => { + resolvePluginWebSearchProviders({ + config: { + plugins: { + entries: { + openrouter: { enabled: true }, + }, + }, + }, + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + plugins: expect.objectContaining({ + entries: expect.objectContaining({ + openrouter: { enabled: true }, + "web-search-brave": { enabled: true }, + "web-search-gemini": { enabled: true }, + "web-search-grok": { enabled: true }, + moonshot: { enabled: true }, + "web-search-perplexity": { enabled: true }, + }), + }), + }), + }), + ); + }); + + it("preserves explicit bundled provider entry state", () => { + resolvePluginWebSearchProviders({ + config: { + plugins: { + entries: { + "web-search-perplexity": { enabled: false }, + }, + }, + }, + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + plugins: expect.objectContaining({ + entries: expect.objectContaining({ + "web-search-perplexity": { enabled: false }, + }), + }), + }), + }), + ); + }); +}); diff --git a/src/plugins/web-search-providers.ts b/src/plugins/web-search-providers.ts new file mode 100644 index 00000000000..1c5b7fb15e6 --- /dev/null +++ b/src/plugins/web-search-providers.ts @@ -0,0 +1,110 @@ +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; +import { createPluginLoaderLogger } from "./logger.js"; +import type { WebSearchProviderPlugin } from "./types.js"; + +const log = createSubsystemLogger("plugins"); + +const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [ + "web-search-brave", + "web-search-gemini", + "web-search-grok", + "moonshot", + "web-search-perplexity", +] as const; + +function withBundledWebSearchAllowlistCompat( + config: PluginLoadOptions["config"], +): PluginLoadOptions["config"] { + const allow = config?.plugins?.allow; + if (!Array.isArray(allow) || allow.length === 0) { + return config; + } + + const allowSet = new Set(allow.map((entry) => entry.trim()).filter(Boolean)); + let changed = false; + for (const pluginId of BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS) { + if (!allowSet.has(pluginId)) { + allowSet.add(pluginId); + changed = true; + } + } + + if (!changed) { + return config; + } + + return { + ...config, + plugins: { + ...config?.plugins, + allow: [...allowSet], + }, + }; +} + +function withBundledWebSearchEnablementCompat( + config: PluginLoadOptions["config"], +): PluginLoadOptions["config"] { + const existingEntries = config?.plugins?.entries ?? {}; + let changed = false; + const nextEntries: Record = { ...existingEntries }; + + for (const pluginId of BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS) { + if (existingEntries[pluginId] !== undefined) { + continue; + } + nextEntries[pluginId] = { enabled: true }; + changed = true; + } + + if (!changed) { + return config; + } + + return { + ...config, + plugins: { + ...config?.plugins, + entries: { + ...existingEntries, + ...nextEntries, + }, + }, + }; +} + +export function resolvePluginWebSearchProviders(params: { + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; + bundledAllowlistCompat?: boolean; +}): WebSearchProviderPlugin[] { + const allowlistCompat = params.bundledAllowlistCompat + ? withBundledWebSearchAllowlistCompat(params.config) + : params.config; + const config = withBundledWebSearchEnablementCompat(allowlistCompat); + const registry = loadOpenClawPlugins({ + config, + workspaceDir: params.workspaceDir, + env: params.env, + logger: createPluginLoaderLogger(log), + activate: false, + cache: false, + onlyPluginIds: [...BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS], + }); + + return registry.webSearchProviders + .map((entry) => ({ + ...entry.provider, + pluginId: entry.pluginId, + })) + .toSorted((a, b) => { + const aOrder = a.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; + const bOrder = b.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; + if (aOrder !== bOrder) { + return aOrder - bOrder; + } + return a.id.localeCompare(b.id); + }); +} diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index 883aac6bd02..71b346cc462 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveSecretInputRef } from "../config/types.secrets.js"; +import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; import { secretRefKey } from "./ref-contract.js"; import { resolveSecretRefValues } from "./resolve.js"; @@ -9,53 +10,28 @@ import { type ResolverContext, type SecretDefaults, } from "./runtime-shared.js"; +import type { + RuntimeWebDiagnostic, + RuntimeWebDiagnosticCode, + RuntimeWebFetchFirecrawlMetadata, + RuntimeWebSearchMetadata, + RuntimeWebToolsMetadata, +} from "./runtime-web-tools.types.js"; -const WEB_SEARCH_PROVIDERS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const; const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai"; const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1"; const PERPLEXITY_KEY_PREFIXES = ["pplx-"]; const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; -type WebSearchProvider = (typeof WEB_SEARCH_PROVIDERS)[number]; +type WebSearchProvider = string; type SecretResolutionSource = "config" | "secretRef" | "env" | "missing"; // pragma: allowlist secret -type RuntimeWebProviderSource = "configured" | "auto-detect" | "none"; - -export type RuntimeWebDiagnosticCode = - | "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT" - | "WEB_SEARCH_AUTODETECT_SELECTED" - | "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED" - | "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK" - | "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED" - | "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK"; - -export type RuntimeWebDiagnostic = { - code: RuntimeWebDiagnosticCode; - message: string; - path?: string; -}; - -export type RuntimeWebSearchMetadata = { - providerConfigured?: WebSearchProvider; - providerSource: RuntimeWebProviderSource; - selectedProvider?: WebSearchProvider; - selectedProviderKeySource?: SecretResolutionSource; - perplexityTransport?: "search_api" | "chat_completions"; - diagnostics: RuntimeWebDiagnostic[]; -}; - -export type RuntimeWebFetchFirecrawlMetadata = { - active: boolean; - apiKeySource: SecretResolutionSource; - diagnostics: RuntimeWebDiagnostic[]; -}; - -export type RuntimeWebToolsMetadata = { - search: RuntimeWebSearchMetadata; - fetch: { - firecrawl: RuntimeWebFetchFirecrawlMetadata; - }; - diagnostics: RuntimeWebDiagnostic[]; +export type { + RuntimeWebDiagnostic, + RuntimeWebDiagnosticCode, + RuntimeWebFetchFirecrawlMetadata, + RuntimeWebSearchMetadata, + RuntimeWebToolsMetadata, }; type FetchConfig = NonNullable["web"] extends infer Web @@ -77,18 +53,15 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function normalizeProvider(value: unknown): WebSearchProvider | undefined { +function normalizeProvider( + value: unknown, + providers: ReturnType, +): WebSearchProvider | undefined { if (typeof value !== "string") { return undefined; } const normalized = value.trim().toLowerCase(); - if ( - normalized === "brave" || - normalized === "gemini" || - normalized === "grok" || - normalized === "kimi" || - normalized === "perplexity" - ) { + if (providers.some((provider) => provider.id === normalized)) { return normalized; } return undefined; @@ -293,16 +266,18 @@ function setResolvedWebSearchApiKey(params: { resolvedConfig: OpenClawConfig; provider: WebSearchProvider; value: string; + sourceConfig: OpenClawConfig; + env: NodeJS.ProcessEnv; }): void { const tools = ensureObject(params.resolvedConfig as Record, "tools"); const web = ensureObject(tools, "web"); const search = ensureObject(web, "search"); - if (params.provider === "brave") { - search.apiKey = params.value; - return; - } - const providerConfig = ensureObject(search, params.provider); - providerConfig.apiKey = params.value; + const provider = resolvePluginWebSearchProviders({ + config: params.sourceConfig, + env: params.env, + bundledAllowlistCompat: true, + }).find((entry) => entry.id === params.provider); + provider?.setCredentialValue(search, params.value); } function setResolvedFirecrawlApiKey(params: { @@ -316,34 +291,8 @@ function setResolvedFirecrawlApiKey(params: { firecrawl.apiKey = params.value; } -function envVarsForProvider(provider: WebSearchProvider): string[] { - if (provider === "brave") { - return ["BRAVE_API_KEY"]; - } - if (provider === "gemini") { - return ["GEMINI_API_KEY"]; - } - if (provider === "grok") { - return ["XAI_API_KEY"]; - } - if (provider === "kimi") { - return ["KIMI_API_KEY", "MOONSHOT_API_KEY"]; - } - return ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"]; -} - -function resolveProviderKeyValue( - search: Record, - provider: WebSearchProvider, -): unknown { - if (provider === "brave") { - return search.apiKey; - } - const scoped = search[provider]; - if (!isRecord(scoped)) { - return undefined; - } - return scoped.apiKey; +function keyPathForProvider(provider: WebSearchProvider): string { + return provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; } function hasConfiguredSecretRef(value: unknown, defaults: SecretDefaults | undefined): boolean { @@ -366,6 +315,11 @@ export async function resolveRuntimeWebTools(params: { const tools = isRecord(params.sourceConfig.tools) ? params.sourceConfig.tools : undefined; const web = isRecord(tools?.web) ? tools.web : undefined; const search = isRecord(web?.search) ? web.search : undefined; + const providers = resolvePluginWebSearchProviders({ + config: params.sourceConfig, + env: params.context.env, + bundledAllowlistCompat: true, + }); const searchMetadata: RuntimeWebSearchMetadata = { providerSource: "none", @@ -375,7 +329,7 @@ export async function resolveRuntimeWebTools(params: { const searchEnabled = search?.enabled !== false; const rawProvider = typeof search?.provider === "string" ? search.provider.trim().toLowerCase() : ""; - const configuredProvider = normalizeProvider(rawProvider); + const configuredProvider = normalizeProvider(rawProvider, providers); if (rawProvider && !configuredProvider) { const diagnostic: RuntimeWebDiagnostic = { @@ -398,7 +352,9 @@ export async function resolveRuntimeWebTools(params: { } if (searchEnabled && search) { - const candidates = configuredProvider ? [configuredProvider] : [...WEB_SEARCH_PROVIDERS]; + const candidates = configuredProvider + ? providers.filter((provider) => provider.id === configuredProvider) + : providers; const unresolvedWithoutFallback: Array<{ provider: WebSearchProvider; path: string; @@ -409,16 +365,15 @@ export async function resolveRuntimeWebTools(params: { let selectedResolution: SecretResolutionResult | undefined; for (const provider of candidates) { - const path = - provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; - const value = resolveProviderKeyValue(search, provider); + const path = keyPathForProvider(provider.id); + const value = provider.getCredentialValue(search); const resolution = await resolveSecretInputWithEnvFallback({ sourceConfig: params.sourceConfig, context: params.context, defaults, value, path, - envVars: envVarsForProvider(provider), + envVars: provider.envVars, }); if (resolution.secretRefConfigured && resolution.fallbackUsedAfterRefFailure) { @@ -440,32 +395,36 @@ export async function resolveRuntimeWebTools(params: { if (resolution.secretRefConfigured && !resolution.value && resolution.unresolvedRefReason) { unresolvedWithoutFallback.push({ - provider, + provider: provider.id, path, reason: resolution.unresolvedRefReason, }); } if (configuredProvider) { - selectedProvider = provider; + selectedProvider = provider.id; selectedResolution = resolution; if (resolution.value) { setResolvedWebSearchApiKey({ resolvedConfig: params.resolvedConfig, - provider, + provider: provider.id, value: resolution.value, + sourceConfig: params.sourceConfig, + env: params.context.env, }); } break; } if (resolution.value) { - selectedProvider = provider; + selectedProvider = provider.id; selectedResolution = resolution; setResolvedWebSearchApiKey({ resolvedConfig: params.resolvedConfig, - provider, + provider: provider.id, value: resolution.value, + sourceConfig: params.sourceConfig, + env: params.context.env, }); break; } @@ -526,13 +485,12 @@ export async function resolveRuntimeWebTools(params: { } if (searchEnabled && search && !configuredProvider && searchMetadata.selectedProvider) { - for (const provider of WEB_SEARCH_PROVIDERS) { - if (provider === searchMetadata.selectedProvider) { + for (const provider of providers) { + if (provider.id === searchMetadata.selectedProvider) { continue; } - const path = - provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; - const value = resolveProviderKeyValue(search, provider); + const path = keyPathForProvider(provider.id); + const value = provider.getCredentialValue(search); if (!hasConfiguredSecretRef(value, defaults)) { continue; } @@ -543,10 +501,9 @@ export async function resolveRuntimeWebTools(params: { }); } } else if (search && !searchEnabled) { - for (const provider of WEB_SEARCH_PROVIDERS) { - const path = - provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; - const value = resolveProviderKeyValue(search, provider); + for (const provider of providers) { + const path = keyPathForProvider(provider.id); + const value = provider.getCredentialValue(search); if (!hasConfiguredSecretRef(value, defaults)) { continue; } @@ -559,13 +516,12 @@ export async function resolveRuntimeWebTools(params: { } if (searchEnabled && search && configuredProvider) { - for (const provider of WEB_SEARCH_PROVIDERS) { - if (provider === configuredProvider) { + for (const provider of providers) { + if (provider.id === configuredProvider) { continue; } - const path = - provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; - const value = resolveProviderKeyValue(search, provider); + const path = keyPathForProvider(provider.id); + const value = provider.getCredentialValue(search); if (!hasConfiguredSecretRef(value, defaults)) { continue; } diff --git a/src/secrets/runtime-web-tools.types.ts b/src/secrets/runtime-web-tools.types.ts new file mode 100644 index 00000000000..fe5fdb24cd0 --- /dev/null +++ b/src/secrets/runtime-web-tools.types.ts @@ -0,0 +1,36 @@ +export type RuntimeWebDiagnosticCode = + | "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT" + | "WEB_SEARCH_AUTODETECT_SELECTED" + | "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED" + | "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK" + | "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED" + | "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK"; + +export type RuntimeWebDiagnostic = { + code: RuntimeWebDiagnosticCode; + message: string; + path?: string; +}; + +export type RuntimeWebSearchMetadata = { + providerConfigured?: string; + providerSource: "configured" | "auto-detect" | "none"; + selectedProvider?: string; + selectedProviderKeySource?: "config" | "secretRef" | "env" | "missing"; + perplexityTransport?: "search_api" | "chat_completions"; + diagnostics: RuntimeWebDiagnostic[]; +}; + +export type RuntimeWebFetchFirecrawlMetadata = { + active: boolean; + apiKeySource: "config" | "secretRef" | "env" | "missing"; + diagnostics: RuntimeWebDiagnostic[]; +}; + +export type RuntimeWebToolsMetadata = { + search: RuntimeWebSearchMetadata; + fetch: { + firecrawl: RuntimeWebFetchFirecrawlMetadata; + }; + diagnostics: RuntimeWebDiagnostic[]; +}; From 3aa5f2703c5e299fad13f8303547e9b96e1b4f14 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 00:40:42 +0000 Subject: [PATCH 082/943] fix(web-search): restore build after plugin rebase --- src/agents/tools/web-search-core.ts | 13 ++++++++++--- src/plugins/web-search-providers.ts | 3 ++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/agents/tools/web-search-core.ts b/src/agents/tools/web-search-core.ts index 48d2d620b49..bebc659c306 100644 --- a/src/agents/tools/web-search-core.ts +++ b/src/agents/tools/web-search-core.ts @@ -23,6 +23,7 @@ import { } from "./web-shared.js"; const SEARCH_PROVIDERS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const; +type SearchProvider = (typeof SEARCH_PROVIDERS)[number]; const DEFAULT_SEARCH_COUNT = 5; const MAX_SEARCH_COUNT = 10; @@ -614,6 +615,10 @@ function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) { }; } +function isSearchProvider(value: string): value is SearchProvider { + return SEARCH_PROVIDERS.includes(value as SearchProvider); +} + function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDERS)[number] { const raw = search && "provider" in search && typeof search.provider === "string" @@ -1911,10 +1916,12 @@ export function createWebSearchTool(options?: { return null; } + const runtimeProviderCandidate = + options?.runtimeWebSearch?.selectedProvider ?? options?.runtimeWebSearch?.providerConfigured; const provider = - options?.runtimeWebSearch?.selectedProvider ?? - options?.runtimeWebSearch?.providerConfigured ?? - resolveSearchProvider(search); + runtimeProviderCandidate && isSearchProvider(runtimeProviderCandidate) + ? runtimeProviderCandidate + : resolveSearchProvider(search); const perplexityConfig = resolvePerplexityConfig(search); const perplexitySchemaTransportHint = options?.runtimeWebSearch?.perplexityTransport ?? diff --git a/src/plugins/web-search-providers.ts b/src/plugins/web-search-providers.ts index 1c5b7fb15e6..00b424977da 100644 --- a/src/plugins/web-search-providers.ts +++ b/src/plugins/web-search-providers.ts @@ -1,3 +1,4 @@ +import type { PluginEntryConfig } from "../config/types.plugins.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; @@ -48,7 +49,7 @@ function withBundledWebSearchEnablementCompat( ): PluginLoadOptions["config"] { const existingEntries = config?.plugins?.entries ?? {}; let changed = false; - const nextEntries: Record = { ...existingEntries }; + const nextEntries: Record = { ...existingEntries }; for (const pluginId of BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS) { if (existingEntries[pluginId] !== undefined) { From 579d0ebe2ba01e2c3b488d764dabf91676df4a08 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:06:12 +0000 Subject: [PATCH 083/943] refactor(web-search): move providers into company plugins --- .../{web-search-brave => brave}/index.ts | 10 ++++----- .../openclaw.plugin.json | 2 +- .../{web-search-grok => brave}/package.json | 4 ++-- .../{web-search-gemini => google}/index.ts | 10 ++++----- .../openclaw.plugin.json | 2 +- .../{web-search-brave => google}/package.json | 4 ++-- .../index.ts | 10 ++++----- .../openclaw.plugin.json | 2 +- .../package.json | 4 ++-- extensions/{web-search-grok => xai}/index.ts | 10 ++++----- .../openclaw.plugin.json | 2 +- .../package.json | 4 ++-- src/plugins/web-search-providers.test.ts | 22 ++++++++----------- src/plugins/web-search-providers.ts | 8 +++---- 14 files changed, 45 insertions(+), 49 deletions(-) rename extensions/{web-search-brave => brave}/index.ts (83%) rename extensions/{web-search-grok => brave}/openclaw.plugin.json (79%) rename extensions/{web-search-grok => brave}/package.json (57%) rename extensions/{web-search-gemini => google}/index.ts (84%) rename extensions/{web-search-brave => google}/openclaw.plugin.json (79%) rename extensions/{web-search-brave => google}/package.json (56%) rename extensions/{web-search-perplexity => perplexity}/index.ts (83%) rename extensions/{web-search-gemini => perplexity}/openclaw.plugin.json (78%) rename extensions/{web-search-gemini => perplexity}/package.json (56%) rename extensions/{web-search-grok => xai}/index.ts (84%) rename extensions/{web-search-perplexity => xai}/openclaw.plugin.json (76%) rename extensions/{web-search-perplexity => xai}/package.json (54%) diff --git a/extensions/web-search-brave/index.ts b/extensions/brave/index.ts similarity index 83% rename from extensions/web-search-brave/index.ts rename to extensions/brave/index.ts index 7345e10f011..1150dec5d80 100644 --- a/extensions/web-search-brave/index.ts +++ b/extensions/brave/index.ts @@ -6,10 +6,10 @@ import { import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; import type { OpenClawPluginApi } from "../../src/plugins/types.js"; -const braveSearchPlugin = { - id: "web-search-brave", - name: "Web Search Brave Provider", - description: "Bundled Brave provider for the web_search tool", +const bravePlugin = { + id: "brave", + name: "Brave Plugin", + description: "Bundled Brave plugin", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { api.registerWebSearchProvider( @@ -29,4 +29,4 @@ const braveSearchPlugin = { }, }; -export default braveSearchPlugin; +export default bravePlugin; diff --git a/extensions/web-search-grok/openclaw.plugin.json b/extensions/brave/openclaw.plugin.json similarity index 79% rename from extensions/web-search-grok/openclaw.plugin.json rename to extensions/brave/openclaw.plugin.json index ccc55644521..404382996d7 100644 --- a/extensions/web-search-grok/openclaw.plugin.json +++ b/extensions/brave/openclaw.plugin.json @@ -1,5 +1,5 @@ { - "id": "web-search-grok", + "id": "brave", "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/web-search-grok/package.json b/extensions/brave/package.json similarity index 57% rename from extensions/web-search-grok/package.json rename to extensions/brave/package.json index 9baa872250e..6756c616e9a 100644 --- a/extensions/web-search-grok/package.json +++ b/extensions/brave/package.json @@ -1,8 +1,8 @@ { - "name": "@openclaw/web-search-grok", + "name": "@openclaw/brave-plugin", "version": "2026.3.14", "private": true, - "description": "OpenClaw Grok web search provider plugin", + "description": "OpenClaw Brave plugin", "type": "module", "openclaw": { "extensions": [ diff --git a/extensions/web-search-gemini/index.ts b/extensions/google/index.ts similarity index 84% rename from extensions/web-search-gemini/index.ts rename to extensions/google/index.ts index 998fbd69a04..5691137070b 100644 --- a/extensions/web-search-gemini/index.ts +++ b/extensions/google/index.ts @@ -6,10 +6,10 @@ import { import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; import type { OpenClawPluginApi } from "../../src/plugins/types.js"; -const geminiSearchPlugin = { - id: "web-search-gemini", - name: "Web Search Gemini Provider", - description: "Bundled Gemini provider for the web_search tool", +const googlePlugin = { + id: "google", + name: "Google Plugin", + description: "Bundled Google plugin", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { api.registerWebSearchProvider( @@ -30,4 +30,4 @@ const geminiSearchPlugin = { }, }; -export default geminiSearchPlugin; +export default googlePlugin; diff --git a/extensions/web-search-brave/openclaw.plugin.json b/extensions/google/openclaw.plugin.json similarity index 79% rename from extensions/web-search-brave/openclaw.plugin.json rename to extensions/google/openclaw.plugin.json index 606091921e9..40594e2f3f9 100644 --- a/extensions/web-search-brave/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -1,5 +1,5 @@ { - "id": "web-search-brave", + "id": "google", "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/web-search-brave/package.json b/extensions/google/package.json similarity index 56% rename from extensions/web-search-brave/package.json rename to extensions/google/package.json index c8807445a28..64c04bc67da 100644 --- a/extensions/web-search-brave/package.json +++ b/extensions/google/package.json @@ -1,8 +1,8 @@ { - "name": "@openclaw/web-search-brave", + "name": "@openclaw/google-plugin", "version": "2026.3.14", "private": true, - "description": "OpenClaw Brave web search provider plugin", + "description": "OpenClaw Google plugin", "type": "module", "openclaw": { "extensions": [ diff --git a/extensions/web-search-perplexity/index.ts b/extensions/perplexity/index.ts similarity index 83% rename from extensions/web-search-perplexity/index.ts rename to extensions/perplexity/index.ts index 83f778aba96..513c70d131d 100644 --- a/extensions/web-search-perplexity/index.ts +++ b/extensions/perplexity/index.ts @@ -6,10 +6,10 @@ import { import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; import type { OpenClawPluginApi } from "../../src/plugins/types.js"; -const perplexitySearchPlugin = { - id: "web-search-perplexity", - name: "Web Search Perplexity Provider", - description: "Bundled Perplexity provider for the web_search tool", +const perplexityPlugin = { + id: "perplexity", + name: "Perplexity Plugin", + description: "Bundled Perplexity plugin", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { api.registerWebSearchProvider( @@ -30,4 +30,4 @@ const perplexitySearchPlugin = { }, }; -export default perplexitySearchPlugin; +export default perplexityPlugin; diff --git a/extensions/web-search-gemini/openclaw.plugin.json b/extensions/perplexity/openclaw.plugin.json similarity index 78% rename from extensions/web-search-gemini/openclaw.plugin.json rename to extensions/perplexity/openclaw.plugin.json index a2baa4b274d..6b976506b65 100644 --- a/extensions/web-search-gemini/openclaw.plugin.json +++ b/extensions/perplexity/openclaw.plugin.json @@ -1,5 +1,5 @@ { - "id": "web-search-gemini", + "id": "perplexity", "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/web-search-gemini/package.json b/extensions/perplexity/package.json similarity index 56% rename from extensions/web-search-gemini/package.json rename to extensions/perplexity/package.json index 1a595b2b060..2a6321ba56c 100644 --- a/extensions/web-search-gemini/package.json +++ b/extensions/perplexity/package.json @@ -1,8 +1,8 @@ { - "name": "@openclaw/web-search-gemini", + "name": "@openclaw/perplexity-plugin", "version": "2026.3.14", "private": true, - "description": "OpenClaw Gemini web search provider plugin", + "description": "OpenClaw Perplexity plugin", "type": "module", "openclaw": { "extensions": [ diff --git a/extensions/web-search-grok/index.ts b/extensions/xai/index.ts similarity index 84% rename from extensions/web-search-grok/index.ts rename to extensions/xai/index.ts index 726879ed43b..dca48a1e466 100644 --- a/extensions/web-search-grok/index.ts +++ b/extensions/xai/index.ts @@ -6,10 +6,10 @@ import { import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; import type { OpenClawPluginApi } from "../../src/plugins/types.js"; -const grokSearchPlugin = { - id: "web-search-grok", - name: "Web Search Grok Provider", - description: "Bundled Grok provider for the web_search tool", +const xaiPlugin = { + id: "xai", + name: "xAI Plugin", + description: "Bundled xAI plugin", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { api.registerWebSearchProvider( @@ -30,4 +30,4 @@ const grokSearchPlugin = { }, }; -export default grokSearchPlugin; +export default xaiPlugin; diff --git a/extensions/web-search-perplexity/openclaw.plugin.json b/extensions/xai/openclaw.plugin.json similarity index 76% rename from extensions/web-search-perplexity/openclaw.plugin.json rename to extensions/xai/openclaw.plugin.json index fc9907a3dc2..507265a4ef3 100644 --- a/extensions/web-search-perplexity/openclaw.plugin.json +++ b/extensions/xai/openclaw.plugin.json @@ -1,5 +1,5 @@ { - "id": "web-search-perplexity", + "id": "xai", "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/web-search-perplexity/package.json b/extensions/xai/package.json similarity index 54% rename from extensions/web-search-perplexity/package.json rename to extensions/xai/package.json index d3724a3b2e3..be904ee3c89 100644 --- a/extensions/web-search-perplexity/package.json +++ b/extensions/xai/package.json @@ -1,8 +1,8 @@ { - "name": "@openclaw/web-search-perplexity", + "name": "@openclaw/xai-plugin", "version": "2026.3.14", "private": true, - "description": "OpenClaw Perplexity web search provider plugin", + "description": "OpenClaw xAI plugin", "type": "module", "openclaw": { "extensions": [ diff --git a/src/plugins/web-search-providers.test.ts b/src/plugins/web-search-providers.test.ts index af794d075c9..2e7b79c64d2 100644 --- a/src/plugins/web-search-providers.test.ts +++ b/src/plugins/web-search-providers.test.ts @@ -13,7 +13,7 @@ describe("resolvePluginWebSearchProviders", () => { loadOpenClawPluginsMock.mockReturnValue({ webSearchProviders: [ { - pluginId: "web-search-gemini", + pluginId: "google", provider: { id: "gemini", label: "Gemini", @@ -25,7 +25,7 @@ describe("resolvePluginWebSearchProviders", () => { }, }, { - pluginId: "web-search-brave", + pluginId: "brave", provider: { id: "brave", label: "Brave", @@ -71,11 +71,7 @@ describe("resolvePluginWebSearchProviders", () => { expect.objectContaining({ config: expect.objectContaining({ plugins: expect.objectContaining({ - allow: expect.arrayContaining([ - "openrouter", - "web-search-brave", - "web-search-perplexity", - ]), + allow: expect.arrayContaining(["openrouter", "brave", "perplexity"]), }), }), }), @@ -99,11 +95,11 @@ describe("resolvePluginWebSearchProviders", () => { plugins: expect.objectContaining({ entries: expect.objectContaining({ openrouter: { enabled: true }, - "web-search-brave": { enabled: true }, - "web-search-gemini": { enabled: true }, - "web-search-grok": { enabled: true }, + brave: { enabled: true }, + google: { enabled: true }, moonshot: { enabled: true }, - "web-search-perplexity": { enabled: true }, + perplexity: { enabled: true }, + xai: { enabled: true }, }), }), }), @@ -116,7 +112,7 @@ describe("resolvePluginWebSearchProviders", () => { config: { plugins: { entries: { - "web-search-perplexity": { enabled: false }, + perplexity: { enabled: false }, }, }, }, @@ -127,7 +123,7 @@ describe("resolvePluginWebSearchProviders", () => { config: expect.objectContaining({ plugins: expect.objectContaining({ entries: expect.objectContaining({ - "web-search-perplexity": { enabled: false }, + perplexity: { enabled: false }, }), }), }), diff --git a/src/plugins/web-search-providers.ts b/src/plugins/web-search-providers.ts index 00b424977da..8120be0113c 100644 --- a/src/plugins/web-search-providers.ts +++ b/src/plugins/web-search-providers.ts @@ -7,11 +7,11 @@ import type { WebSearchProviderPlugin } from "./types.js"; const log = createSubsystemLogger("plugins"); const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [ - "web-search-brave", - "web-search-gemini", - "web-search-grok", + "brave", + "google", "moonshot", - "web-search-perplexity", + "perplexity", + "xai", ] as const; function withBundledWebSearchAllowlistCompat( From 7a93f7d9dfe63a40793613ad6290fc3f53d593a7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 18:08:09 -0700 Subject: [PATCH 084/943] WhatsApp: lazy-load setup wizard surface --- extensions/whatsapp/src/channel.runtime.ts | 1 + extensions/whatsapp/src/channel.ts | 53 +++++++++++++++++++++- extensions/whatsapp/src/setup-core.ts | 52 +++++++++++++++++++++ extensions/whatsapp/src/setup-surface.ts | 50 +------------------- 4 files changed, 105 insertions(+), 51 deletions(-) create mode 100644 extensions/whatsapp/src/channel.runtime.ts create mode 100644 extensions/whatsapp/src/setup-core.ts diff --git a/extensions/whatsapp/src/channel.runtime.ts b/extensions/whatsapp/src/channel.runtime.ts new file mode 100644 index 00000000000..ff67d34ee10 --- /dev/null +++ b/extensions/whatsapp/src/channel.runtime.ts @@ -0,0 +1 @@ +export { whatsappSetupWizard } from "./setup-surface.js"; diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index e240824c743..63c01bca05c 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -33,11 +33,60 @@ import { } from "./accounts.js"; import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./normalize.js"; import { getWhatsAppRuntime } from "./runtime.js"; -import { whatsappSetupAdapter, whatsappSetupWizard } from "./setup-surface.js"; +import { whatsappSetupAdapter } from "./setup-core.js"; import { collectWhatsAppStatusIssues } from "./status-issues.js"; const meta = getChatChannelMeta("whatsapp"); +async function loadWhatsAppChannelRuntime() { + return await import("./channel.runtime.js"); +} + +const whatsappSetupWizardProxy = { + channel: "whatsapp", + status: { + configuredLabel: "linked", + unconfiguredLabel: "not linked", + configuredHint: "linked", + unconfiguredHint: "not linked", + configuredScore: 5, + unconfiguredScore: 4, + resolveConfigured: async ({ cfg }) => + await ( + await loadWhatsAppChannelRuntime() + ).whatsappSetupWizard.status.resolveConfigured({ + cfg, + }), + resolveStatusLines: async ({ cfg, configured }) => + await ( + await loadWhatsAppChannelRuntime() + ).whatsappSetupWizard.status.resolveStatusLines?.({ + cfg, + configured, + }), + }, + resolveShouldPromptAccountIds: (params) => + (params.shouldPromptAccountIds || params.options?.promptWhatsAppAccountId) ?? false, + credentials: [], + finalize: async (params) => + await ( + await loadWhatsAppChannelRuntime() + ).whatsappSetupWizard.finalize!(params), + disable: (cfg) => ({ + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + enabled: false, + }, + }, + }), + onAccountRecorded: (accountId, options) => { + options?.onWhatsAppAccountId?.(accountId); + }, +} satisfies NonNullable["setupWizard"]>; + export const whatsappPlugin: ChannelPlugin = { id: "whatsapp", meta: { @@ -47,7 +96,7 @@ export const whatsappPlugin: ChannelPlugin = { forceAccountBinding: true, preferSessionLookupForAnnounceTarget: true, }, - setupWizard: whatsappSetupWizard, + setupWizard: whatsappSetupWizardProxy, agentTools: () => [getWhatsAppRuntime().channel.whatsapp.createLoginTool()], pairing: { idLabel: "whatsappSenderId", diff --git a/extensions/whatsapp/src/setup-core.ts b/extensions/whatsapp/src/setup-core.ts new file mode 100644 index 00000000000..2b243743076 --- /dev/null +++ b/extensions/whatsapp/src/setup-core.ts @@ -0,0 +1,52 @@ +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; + +const channel = "whatsapp" as const; + +export const whatsappSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + alwaysUseAccounts: true, + }), + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + alwaysUseAccounts: true, + }); + const next = migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + alwaysUseAccounts: true, + }); + const entry = { + ...next.channels?.whatsapp?.accounts?.[accountId], + ...(input.authDir ? { authDir: input.authDir } : {}), + enabled: true, + }; + return { + ...next, + channels: { + ...next.channels, + whatsapp: { + ...next.channels?.whatsapp, + accounts: { + ...next.channels?.whatsapp?.accounts, + [accountId]: entry, + }, + }, + }, + }; + }, +}; diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index 180f84a3fbf..e0e9fa3191b 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -5,12 +5,7 @@ import { splitOnboardingEntries, } from "../../../src/channels/plugins/onboarding/helpers.js"; import { setOnboardingChannelEnabled } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import { formatCliCommand } from "../../../src/cli/command-format.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { mergeWhatsAppConfig } from "../../../src/config/merge-config.js"; @@ -19,6 +14,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/ses import { formatDocsLink } from "../../../src/terminal/links.js"; import { normalizeE164, pathExists } from "../../../src/utils.js"; import { listWhatsAppAccountIds, resolveWhatsAppAuthDir } from "./accounts.js"; +import { whatsappSetupAdapter } from "./setup-core.js"; const channel = "whatsapp" as const; @@ -247,50 +243,6 @@ async function promptWhatsAppDmAccess(params: { return setWhatsAppAllowFrom(next, parsed.entries); } -export const whatsappSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - alwaysUseAccounts: true, - }), - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - alwaysUseAccounts: true, - }); - const next = migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - alwaysUseAccounts: true, - }); - const entry = { - ...next.channels?.whatsapp?.accounts?.[accountId], - ...(input.authDir ? { authDir: input.authDir } : {}), - enabled: true, - }; - return { - ...next, - channels: { - ...next.channels, - whatsapp: { - ...next.channels?.whatsapp, - accounts: { - ...next.channels?.whatsapp?.accounts, - [accountId]: entry, - }, - }, - }, - }; - }, -}; - export const whatsappSetupWizard: ChannelSetupWizard = { channel, status: { From b8dbc12560e8b11d60da888bf2b632af37f83fa4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:10:22 +0000 Subject: [PATCH 085/943] fix: align channel adapters with plugin sdk --- extensions/feishu/src/channel.ts | 5 +++-- extensions/matrix/src/channel.ts | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 17f3e5cc580..ecfd27194b7 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -65,10 +65,11 @@ const feishuOnboarding = { }, }, }), - promptAllowFrom: async (cfg, prompter) => - (await loadFeishuChannelRuntime()).feishuOnboardingAdapter.dmPolicy!.promptAllowFrom({ + promptAllowFrom: async ({ cfg, prompter, accountId }) => + (await loadFeishuChannelRuntime()).feishuOnboardingAdapter.dmPolicy!.promptAllowFrom!({ cfg, prompter, + accountId, }), }, disable: (cfg) => ({ diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index c9f95d3d671..0522590356a 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -382,10 +382,10 @@ export const matrixPlugin: ChannelPlugin = { chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async (params) => (await loadMatrixChannelRuntime()).matrixOutbound.sendText(params), + sendText: async (params) => (await loadMatrixChannelRuntime()).matrixOutbound.sendText!(params), sendMedia: async (params) => - (await loadMatrixChannelRuntime()).matrixOutbound.sendMedia(params), - sendPoll: async (params) => (await loadMatrixChannelRuntime()).matrixOutbound.sendPoll(params), + (await loadMatrixChannelRuntime()).matrixOutbound.sendMedia!(params), + sendPoll: async (params) => (await loadMatrixChannelRuntime()).matrixOutbound.sendPoll!(params), }, status: { defaultRuntime: { From d56559bad7dfb60a2a83f5e00c23a6185883b910 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:15:31 +0000 Subject: [PATCH 086/943] fix: repair node24 ci type drift --- extensions/lobster/src/lobster-tool.test.ts | 1 + extensions/test-utils/plugin-api.ts | 1 + extensions/whatsapp/src/channel.ts | 4 +-- src/agents/tools/web-search-plugin-factory.ts | 13 +++++-- src/auto-reply/reply/route-reply.test.ts | 1 + src/commands/configure.wizard.ts | 11 +++--- src/commands/onboard-search.ts | 36 +++++++++++++------ .../onboarding/plugin-install.test.ts | 1 + src/config/test-helpers.ts | 5 ++- src/gateway/server-plugins.test.ts | 1 + ...server.agent.gateway-server-agent.mocks.ts | 1 + src/gateway/test-helpers.mocks.ts | 1 + src/test-utils/channel-plugins.ts | 1 + 13 files changed, 58 insertions(+), 19 deletions(-) diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index bde3767845c..21d090846b0 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -44,6 +44,7 @@ function fakeApi(overrides: Partial = {}): OpenClawPluginApi registerCli() {}, registerService() {}, registerProvider() {}, + registerWebSearchProvider() {}, registerInteractiveHandler() {}, registerHook() {}, registerHttpRoute() {}, diff --git a/extensions/test-utils/plugin-api.ts b/extensions/test-utils/plugin-api.ts index c2eaeced2e5..5c621700602 100644 --- a/extensions/test-utils/plugin-api.ts +++ b/extensions/test-utils/plugin-api.ts @@ -15,6 +15,7 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi registerCli() {}, registerService() {}, registerProvider() {}, + registerWebSearchProvider() {}, registerInteractiveHandler() {}, registerCommand() {}, registerContextEngine() {}, diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 63c01bca05c..d73c951a054 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -58,12 +58,12 @@ const whatsappSetupWizardProxy = { cfg, }), resolveStatusLines: async ({ cfg, configured }) => - await ( + (await ( await loadWhatsAppChannelRuntime() ).whatsappSetupWizard.status.resolveStatusLines?.({ cfg, configured, - }), + })) ?? [], }, resolveShouldPromptAccountIds: (params) => (params.shouldPromptAccountIds || params.options?.promptWhatsAppAccountId) ?? false, diff --git a/src/agents/tools/web-search-plugin-factory.ts b/src/agents/tools/web-search-plugin-factory.ts index 8022b2e354d..ab80702a6ed 100644 --- a/src/agents/tools/web-search-plugin-factory.ts +++ b/src/agents/tools/web-search-plugin-factory.ts @@ -2,6 +2,10 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { WebSearchProviderPlugin } from "../../plugins/types.js"; import { createWebSearchTool as createLegacyWebSearchTool } from "./web-search-core.js"; +type ConfiguredWebSearchProvider = NonNullable< + NonNullable["web"]>["search"] +>["provider"]; + function cloneWithDescriptors(value: T | undefined): T { const next = Object.create(Object.getPrototypeOf(value ?? {})) as T; if (value) { @@ -10,7 +14,10 @@ function cloneWithDescriptors(value: T | undefined): T { return next; } -function withForcedProvider(config: OpenClawConfig | undefined, provider: string): OpenClawConfig { +function withForcedProvider( + config: OpenClawConfig | undefined, + provider: ConfiguredWebSearchProvider, +): OpenClawConfig { const next = cloneWithDescriptors(config ?? {}); const tools = cloneWithDescriptors(next.tools ?? {}); const web = cloneWithDescriptors(tools.web ?? {}); @@ -25,7 +32,9 @@ function withForcedProvider(config: OpenClawConfig | undefined, provider: string } export function createPluginBackedWebSearchProvider( - provider: Omit, + provider: Omit & { + id: ConfiguredWebSearchProvider; + }, ): WebSearchProviderPlugin { return { ...provider, diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index ed507607c83..0a717f9bfc7 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -88,6 +88,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => enabled: true, })), providers: [], + webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], cliRegistrars: [], diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 80af67043ab..6e5f0203be0 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -174,6 +174,10 @@ async function promptWebToolsConfig( hasKeyInEnv, } = await import("./onboard-search.js"); type SP = (typeof SEARCH_PROVIDER_OPTIONS)[number]["value"]; + const defaultProvider = SEARCH_PROVIDER_OPTIONS[0]?.value; + if (!defaultProvider) { + throw new Error("No web search providers are registered."); + } const hasKeyForProvider = (provider: string): boolean => { const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === provider); @@ -183,14 +187,13 @@ async function promptWebToolsConfig( return hasExistingKey(nextConfig, provider as SP) || hasKeyInEnv(entry); }; - const existingProvider: string = (() => { + const existingProvider: SP = (() => { const stored = existingSearch?.provider; if (stored && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === stored)) { - return stored; + return stored as SP; } return ( - SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ?? - SEARCH_PROVIDER_OPTIONS[0].value + SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ?? defaultProvider ); })(); diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index d1281fe3fc7..af5f3cd9a8f 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -11,7 +11,21 @@ import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { SecretInputMode } from "./onboard-types.js"; -export type SearchProvider = string; +export type SearchProvider = NonNullable< + NonNullable["web"]>["search"]>["provider"] +>; + +const SEARCH_PROVIDER_IDS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const; + +function isSearchProvider(value: string): value is SearchProvider { + return (SEARCH_PROVIDER_IDS as readonly string[]).includes(value); +} + +function hasSearchProviderId( + provider: T, +): provider is T & { id: SearchProvider } { + return isSearchProvider(provider.id); +} type SearchProviderEntry = { value: SearchProvider; @@ -25,14 +39,16 @@ type SearchProviderEntry = { export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] = resolvePluginWebSearchProviders({ bundledAllowlistCompat: true, - }).map((provider) => ({ - value: provider.id, - label: provider.label, - hint: provider.hint, - envKeys: provider.envVars, - placeholder: provider.placeholder, - signupUrl: provider.signupUrl, - })); + }) + .filter(hasSearchProviderId) + .map((provider) => ({ + value: provider.id, + label: provider.label, + hint: provider.hint, + envKeys: provider.envVars, + placeholder: provider.placeholder, + signupUrl: provider.signupUrl, + })); export function hasKeyInEnv(entry: SearchProviderEntry): boolean { return entry.envKeys.some((k) => Boolean(process.env[k]?.trim())); @@ -178,7 +194,7 @@ export async function setupSearch( return SEARCH_PROVIDER_OPTIONS[0].value; })(); - type PickerValue = string; + type PickerValue = SearchProvider | "__skip__"; const choice = await prompter.select({ message: "Search provider", options: [ diff --git a/src/commands/onboarding/plugin-install.test.ts b/src/commands/onboarding/plugin-install.test.ts index d2c55d330c7..1cd9e530b86 100644 --- a/src/commands/onboarding/plugin-install.test.ts +++ b/src/commands/onboarding/plugin-install.test.ts @@ -335,6 +335,7 @@ describe("ensureOnboardingPluginInstalled", () => { hookNames: [], channelIds: [], providerIds: [], + webSearchProviderIds: [], gatewayMethods: [], cliCommands: [], services: [], diff --git a/src/config/test-helpers.ts b/src/config/test-helpers.ts index 69e7745a85b..5809a37da2d 100644 --- a/src/config/test-helpers.ts +++ b/src/config/test-helpers.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; +import type { OpenClawConfig } from "./config.js"; export async function withTempHome(fn: (home: string) => Promise): Promise { return withTempHomeBase(fn, { prefix: "openclaw-config-" }); @@ -53,7 +54,9 @@ export async function withEnvOverride( } export function buildWebSearchProviderConfig(params: { - provider: string; + provider: NonNullable< + NonNullable["web"]>["search"]>["provider"] + >; enabled?: boolean; providerConfig?: Record; }): Record { diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 560392499c1..2db21cccde1 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -29,6 +29,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ channelSetups: [], commands: [], providers: [], + webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], cliRegistrars: [], diff --git a/src/gateway/server.agent.gateway-server-agent.mocks.ts b/src/gateway/server.agent.gateway-server-agent.mocks.ts index 0e1f779ef4f..acf507dbde2 100644 --- a/src/gateway/server.agent.gateway-server-agent.mocks.ts +++ b/src/gateway/server.agent.gateway-server-agent.mocks.ts @@ -11,6 +11,7 @@ export const registryState: { registry: PluginRegistry } = { channels: [], channelSetups: [], providers: [], + webSearchProviders: [], gatewayHandlers: {}, httpHandlers: [], httpRoutes: [], diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 17868ae0bca..59ad8a9cedc 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -146,6 +146,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({ ], channelSetups: [], providers: [], + webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], cliRegistrars: [], diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index ebec4f2c747..2af1191feba 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -25,6 +25,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl enabled: true, })), providers: [], + webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], cliRegistrars: [], From bc5054ce686cabf9a61fcd17d636a7679ba7e921 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:13:17 +0000 Subject: [PATCH 087/943] refactor(google): merge gemini auth into google plugin --- docs/concepts/model-providers.md | 15 +- docs/help/faq.md | 2 +- docs/tools/plugin.md | 6 +- extensions/google-gemini-cli-auth/README.md | 41 ----- extensions/google-gemini-cli-auth/index.ts | 158 ------------------ .../openclaw.plugin.json | 9 - .../google-gemini-cli-auth/package.json | 12 -- .../gemini-cli-provider.test.ts} | 30 +++- extensions/google/gemini-cli-provider.ts | 149 +++++++++++++++++ extensions/google/index.ts | 2 + .../oauth.test.ts | 5 +- .../oauth.ts | 3 +- extensions/google/openclaw.plugin.json | 1 + package.json | 4 - scripts/check-no-raw-channel-fetch.mjs | 5 - scripts/check-plugin-sdk-exports.mjs | 1 - scripts/release-check.ts | 2 - scripts/write-plugin-sdk-entry-dts.ts | 1 - ...uth-choice.apply.google-gemini-cli.test.ts | 2 +- .../auth-choice.apply.google-gemini-cli.ts | 2 +- src/config/plugin-auto-enable.test.ts | 2 +- src/config/plugin-auto-enable.ts | 2 +- src/plugin-sdk/google-gemini-cli-auth.ts | 15 -- src/plugin-sdk/index.test.ts | 1 - src/plugin-sdk/subpaths.test.ts | 4 - src/plugins/enable.test.ts | 12 +- src/plugins/providers.ts | 2 +- tsconfig.plugin-sdk.dts.json | 1 - tsdown.config.ts | 1 - vitest.config.ts | 1 - 30 files changed, 200 insertions(+), 291 deletions(-) delete mode 100644 extensions/google-gemini-cli-auth/README.md delete mode 100644 extensions/google-gemini-cli-auth/index.ts delete mode 100644 extensions/google-gemini-cli-auth/openclaw.plugin.json delete mode 100644 extensions/google-gemini-cli-auth/package.json rename extensions/{google-gemini-cli-auth/index.test.ts => google/gemini-cli-provider.test.ts} (79%) create mode 100644 extensions/google/gemini-cli-provider.ts rename extensions/{google-gemini-cli-auth => google}/oauth.test.ts (99%) rename extensions/{google-gemini-cli-auth => google}/oauth.ts (99%) delete mode 100644 src/plugin-sdk/google-gemini-cli-auth.ts diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 3a29c373c1d..d20b5055763 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -176,16 +176,13 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Compatibility: legacy OpenClaw config using `google/gemini-3.1-flash-preview` is normalized to `google/gemini-3-flash-preview` - CLI: `openclaw onboard --auth-choice gemini-api-key` -### Google Vertex, Antigravity, and Gemini CLI +### Google Vertex and Gemini CLI -- Providers: `google-vertex`, `google-antigravity`, `google-gemini-cli` -- Auth: Vertex uses gcloud ADC; Antigravity/Gemini CLI use their respective auth flows -- Caution: Antigravity and Gemini CLI OAuth in OpenClaw are unofficial integrations. Some users have reported Google account restrictions after using third-party clients. Review Google terms and use a non-critical account if you choose to proceed. -- Antigravity OAuth is shipped as a bundled plugin (`google-antigravity-auth`, disabled by default). - - Enable: `openclaw plugins enable google-antigravity-auth` - - Login: `openclaw models auth login --provider google-antigravity --set-default` -- Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default). - - Enable: `openclaw plugins enable google-gemini-cli-auth` +- Providers: `google-vertex`, `google-gemini-cli` +- Auth: Vertex uses gcloud ADC; Gemini CLI uses its OAuth flow +- Caution: Gemini CLI OAuth in OpenClaw is an unofficial integration. Some users have reported Google account restrictions after using third-party clients. Review Google terms and use a non-critical account if you choose to proceed. +- Gemini CLI OAuth is shipped as part of the bundled `google` plugin. + - Enable: `openclaw plugins enable google` - Login: `openclaw models auth login --provider google-gemini-cli --set-default` - Note: you do **not** paste a client id or secret into `openclaw.json`. The CLI login flow stores tokens in auth profiles on the gateway host. diff --git a/docs/help/faq.md b/docs/help/faq.md index 236097634c1..c402230aaa3 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -783,7 +783,7 @@ Gemini CLI uses a **plugin auth flow**, not a client id or secret in `openclaw.j Steps: -1. Enable the plugin: `openclaw plugins enable google-gemini-cli-auth` +1. Enable the plugin: `openclaw plugins enable google` 2. Login: `openclaw models auth login --provider google-gemini-cli --set-default` This stores OAuth tokens in auth profiles on the gateway host. Details: [Model providers](/concepts/model-providers). diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 8aa7beefa42..59752ddf253 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -167,8 +167,7 @@ Important trust note: - Anthropic provider runtime — bundled as `anthropic` (enabled by default) - BytePlus provider catalog — bundled as `byteplus` (enabled by default) - Cloudflare AI Gateway provider catalog — bundled as `cloudflare-ai-gateway` (enabled by default) -- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default) -- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default) +- Google web search + Gemini CLI OAuth — bundled as `google` (web search auto-loads it; provider auth stays opt-in) - GitHub Copilot provider runtime — bundled as `github-copilot` (enabled by default) - Hugging Face provider catalog — bundled as `huggingface` (enabled by default) - Kilo Gateway provider runtime — bundled as `kilocode` (enabled by default) @@ -521,8 +520,7 @@ authoring plugins: `openclaw/plugin-sdk/acpx`, `openclaw/plugin-sdk/bluebubbles`, `openclaw/plugin-sdk/copilot-proxy`, `openclaw/plugin-sdk/device-pair`, `openclaw/plugin-sdk/diagnostics-otel`, `openclaw/plugin-sdk/diffs`, - `openclaw/plugin-sdk/feishu`, - `openclaw/plugin-sdk/google-gemini-cli-auth`, `openclaw/plugin-sdk/googlechat`, + `openclaw/plugin-sdk/feishu`, `openclaw/plugin-sdk/googlechat`, `openclaw/plugin-sdk/irc`, `openclaw/plugin-sdk/llm-task`, `openclaw/plugin-sdk/lobster`, `openclaw/plugin-sdk/matrix`, `openclaw/plugin-sdk/mattermost`, `openclaw/plugin-sdk/memory-core`, diff --git a/extensions/google-gemini-cli-auth/README.md b/extensions/google-gemini-cli-auth/README.md deleted file mode 100644 index bbca53ba1ce..00000000000 --- a/extensions/google-gemini-cli-auth/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# Google Gemini CLI Auth (OpenClaw plugin) - -OAuth provider plugin for **Gemini CLI** (Google Code Assist). - -## Account safety caution - -- This plugin is an unofficial integration and is not endorsed by Google. -- Some users have reported account restrictions or suspensions after using third-party Gemini CLI and Antigravity OAuth clients. -- Use caution, review the applicable Google terms, and avoid using a mission-critical account. - -## Enable - -Bundled plugins are disabled by default. Enable this one: - -```bash -openclaw plugins enable google-gemini-cli-auth -``` - -Restart the Gateway after enabling. - -## Authenticate - -```bash -openclaw models auth login --provider google-gemini-cli --set-default -``` - -## Requirements - -Requires the Gemini CLI to be installed (credentials are extracted automatically): - -```bash -brew install gemini-cli -# or: npm install -g @google/gemini-cli -``` - -## Env vars (optional) - -Override auto-detected credentials with: - -- `OPENCLAW_GEMINI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID` -- `OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET` diff --git a/extensions/google-gemini-cli-auth/index.ts b/extensions/google-gemini-cli-auth/index.ts deleted file mode 100644 index 290cc19598f..00000000000 --- a/extensions/google-gemini-cli-auth/index.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { - buildOauthProviderAuthResult, - emptyPluginConfigSchema, - type ProviderFetchUsageSnapshotContext, - type OpenClawPluginApi, - type ProviderAuthContext, - type ProviderResolveDynamicModelContext, - type ProviderRuntimeModel, -} from "openclaw/plugin-sdk/google-gemini-cli-auth"; -import { normalizeModelCompat } from "../../src/agents/model-compat.js"; -import { fetchGeminiUsage } from "../../src/infra/provider-usage.fetch.js"; -import { loginGeminiCliOAuth } from "./oauth.js"; - -const PROVIDER_ID = "google-gemini-cli"; -const PROVIDER_LABEL = "Gemini CLI OAuth"; -const DEFAULT_MODEL = "google-gemini-cli/gemini-3.1-pro-preview"; -const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro"; -const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash"; -const GEMINI_3_1_PRO_TEMPLATE_IDS = ["gemini-3-pro-preview"] as const; -const GEMINI_3_1_FLASH_TEMPLATE_IDS = ["gemini-3-flash-preview"] as const; -const ENV_VARS = [ - "OPENCLAW_GEMINI_OAUTH_CLIENT_ID", - "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", - "GEMINI_CLI_OAUTH_CLIENT_ID", - "GEMINI_CLI_OAUTH_CLIENT_SECRET", -]; - -function cloneFirstTemplateModel(params: { - modelId: string; - templateIds: readonly string[]; - ctx: ProviderResolveDynamicModelContext; -}): ProviderRuntimeModel | undefined { - const trimmedModelId = params.modelId.trim(); - for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { - const template = params.ctx.modelRegistry.find( - PROVIDER_ID, - templateId, - ) as ProviderRuntimeModel | null; - if (!template) { - continue; - } - return normalizeModelCompat({ - ...template, - id: trimmedModelId, - name: trimmedModelId, - reasoning: true, - } as ProviderRuntimeModel); - } - return undefined; -} - -function parseGoogleUsageToken(apiKey: string): string { - try { - const parsed = JSON.parse(apiKey) as { token?: unknown }; - if (typeof parsed?.token === "string") { - return parsed.token; - } - } catch { - // ignore - } - return apiKey; -} - -async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) { - return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID); -} - -function resolveGeminiCliForwardCompatModel( - ctx: ProviderResolveDynamicModelContext, -): ProviderRuntimeModel | undefined { - const trimmed = ctx.modelId.trim(); - const lower = trimmed.toLowerCase(); - - let templateIds: readonly string[]; - if (lower.startsWith(GEMINI_3_1_PRO_PREFIX)) { - templateIds = GEMINI_3_1_PRO_TEMPLATE_IDS; - } else if (lower.startsWith(GEMINI_3_1_FLASH_PREFIX)) { - templateIds = GEMINI_3_1_FLASH_TEMPLATE_IDS; - } else { - return undefined; - } - - return cloneFirstTemplateModel({ - modelId: trimmed, - templateIds, - ctx, - }); -} - -const geminiCliPlugin = { - id: "google-gemini-cli-auth", - name: "Google Gemini CLI Auth", - description: "OAuth flow for Gemini CLI (Google Code Assist)", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { - api.registerProvider({ - id: PROVIDER_ID, - label: PROVIDER_LABEL, - docsPath: "/providers/models", - aliases: ["gemini-cli"], - envVars: ENV_VARS, - auth: [ - { - id: "oauth", - label: "Google OAuth", - hint: "PKCE + localhost callback", - kind: "oauth", - run: async (ctx: ProviderAuthContext) => { - const spin = ctx.prompter.progress("Starting Gemini CLI OAuth…"); - try { - const result = await loginGeminiCliOAuth({ - isRemote: ctx.isRemote, - openUrl: ctx.openUrl, - log: (msg) => ctx.runtime.log(msg), - note: ctx.prompter.note, - prompt: async (message) => String(await ctx.prompter.text({ message })), - progress: spin, - }); - - spin.stop("Gemini CLI OAuth complete"); - return buildOauthProviderAuthResult({ - providerId: PROVIDER_ID, - defaultModel: DEFAULT_MODEL, - access: result.access, - refresh: result.refresh, - expires: result.expires, - email: result.email, - credentialExtra: { projectId: result.projectId }, - notes: ["If requests fail, set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID."], - }); - } catch (err) { - spin.stop("Gemini CLI OAuth failed"); - await ctx.prompter.note( - "Trouble with OAuth? Ensure your Google account has Gemini CLI access.", - "OAuth help", - ); - throw err; - } - }, - }, - ], - resolveDynamicModel: (ctx) => resolveGeminiCliForwardCompatModel(ctx), - resolveUsageAuth: async (ctx) => { - const auth = await ctx.resolveOAuthToken(); - if (!auth) { - return null; - } - return { - ...auth, - token: parseGoogleUsageToken(auth.token), - }; - }, - fetchUsageSnapshot: async (ctx) => await fetchGeminiCliUsage(ctx), - }); - }, -}; - -export default geminiCliPlugin; diff --git a/extensions/google-gemini-cli-auth/openclaw.plugin.json b/extensions/google-gemini-cli-auth/openclaw.plugin.json deleted file mode 100644 index c8f632da0c8..00000000000 --- a/extensions/google-gemini-cli-auth/openclaw.plugin.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": "google-gemini-cli-auth", - "providers": ["google-gemini-cli"], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": {} - } -} diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json deleted file mode 100644 index 61ae5be803c..00000000000 --- a/extensions/google-gemini-cli-auth/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@openclaw/google-gemini-cli-auth", - "version": "2026.3.14", - "private": true, - "description": "OpenClaw Gemini CLI OAuth provider plugin", - "type": "module", - "openclaw": { - "extensions": [ - "./index.ts" - ] - } -} diff --git a/extensions/google-gemini-cli-auth/index.test.ts b/extensions/google/gemini-cli-provider.test.ts similarity index 79% rename from extensions/google-gemini-cli-auth/index.test.ts rename to extensions/google/gemini-cli-provider.test.ts index d0542e3473c..ad5969c7c4d 100644 --- a/extensions/google-gemini-cli-auth/index.test.ts +++ b/extensions/google/gemini-cli-provider.test.ts @@ -4,24 +4,38 @@ import { createProviderUsageFetch, makeResponse, } from "../../src/test-utils/provider-usage-fetch.js"; -import geminiCliPlugin from "./index.js"; +import googlePlugin from "./index.js"; -function registerProvider(): ProviderPlugin { +function registerGooglePlugin(): { + provider: ProviderPlugin; + webSearchProviderRegistered: boolean; +} { let provider: ProviderPlugin | undefined; - geminiCliPlugin.register({ + let webSearchProviderRegistered = false; + googlePlugin.register({ registerProvider(nextProvider: ProviderPlugin) { provider = nextProvider; }, + registerWebSearchProvider() { + webSearchProviderRegistered = true; + }, } as never); if (!provider) { throw new Error("provider registration missing"); } - return provider; + return { provider, webSearchProviderRegistered }; } -describe("google-gemini-cli-auth plugin", () => { +describe("google plugin", () => { + it("registers both Gemini CLI auth and Gemini web search", () => { + const result = registerGooglePlugin(); + + expect(result.provider.id).toBe("google-gemini-cli"); + expect(result.webSearchProviderRegistered).toBe(true); + }); + it("owns gemini 3.1 forward-compat resolution", () => { - const provider = registerProvider(); + const { provider } = registerGooglePlugin(); const model = provider.resolveDynamicModel?.({ provider: "google-gemini-cli", modelId: "gemini-3.1-pro-preview", @@ -52,7 +66,7 @@ describe("google-gemini-cli-auth plugin", () => { }); it("owns usage-token parsing", async () => { - const provider = registerProvider(); + const { provider } = registerGooglePlugin(); await expect( provider.resolveUsageAuth?.({ config: {} as never, @@ -71,7 +85,7 @@ describe("google-gemini-cli-auth plugin", () => { }); it("owns usage snapshot fetching", async () => { - const provider = registerProvider(); + const { provider } = registerGooglePlugin(); const mockFetch = createProviderUsageFetch(async (url) => { if (url.includes("cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota")) { return makeResponse(200, { diff --git a/extensions/google/gemini-cli-provider.ts b/extensions/google/gemini-cli-provider.ts new file mode 100644 index 00000000000..b4bb58f7d80 --- /dev/null +++ b/extensions/google/gemini-cli-provider.ts @@ -0,0 +1,149 @@ +import { normalizeModelCompat } from "../../src/agents/model-compat.js"; +import { fetchGeminiUsage } from "../../src/infra/provider-usage.fetch.js"; +import { buildOauthProviderAuthResult } from "../../src/plugin-sdk/provider-auth-result.js"; +import type { + OpenClawPluginApi, + ProviderAuthContext, + ProviderFetchUsageSnapshotContext, + ProviderResolveDynamicModelContext, + ProviderRuntimeModel, +} from "../../src/plugins/types.js"; +import { loginGeminiCliOAuth } from "./oauth.js"; + +const PROVIDER_ID = "google-gemini-cli"; +const PROVIDER_LABEL = "Gemini CLI OAuth"; +const DEFAULT_MODEL = "google-gemini-cli/gemini-3.1-pro-preview"; +const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro"; +const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash"; +const GEMINI_3_1_PRO_TEMPLATE_IDS = ["gemini-3-pro-preview"] as const; +const GEMINI_3_1_FLASH_TEMPLATE_IDS = ["gemini-3-flash-preview"] as const; +const ENV_VARS = [ + "OPENCLAW_GEMINI_OAUTH_CLIENT_ID", + "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", + "GEMINI_CLI_OAUTH_CLIENT_ID", + "GEMINI_CLI_OAUTH_CLIENT_SECRET", +]; + +function cloneFirstTemplateModel(params: { + modelId: string; + templateIds: readonly string[]; + ctx: ProviderResolveDynamicModelContext; +}): ProviderRuntimeModel | undefined { + const trimmedModelId = params.modelId.trim(); + for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { + const template = params.ctx.modelRegistry.find( + PROVIDER_ID, + templateId, + ) as ProviderRuntimeModel | null; + if (!template) { + continue; + } + return normalizeModelCompat({ + ...template, + id: trimmedModelId, + name: trimmedModelId, + reasoning: true, + } as ProviderRuntimeModel); + } + return undefined; +} + +function parseGoogleUsageToken(apiKey: string): string { + try { + const parsed = JSON.parse(apiKey) as { token?: unknown }; + if (typeof parsed?.token === "string") { + return parsed.token; + } + } catch { + // ignore + } + return apiKey; +} + +async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) { + return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID); +} + +function resolveGeminiCliForwardCompatModel( + ctx: ProviderResolveDynamicModelContext, +): ProviderRuntimeModel | undefined { + const trimmed = ctx.modelId.trim(); + const lower = trimmed.toLowerCase(); + + let templateIds: readonly string[]; + if (lower.startsWith(GEMINI_3_1_PRO_PREFIX)) { + templateIds = GEMINI_3_1_PRO_TEMPLATE_IDS; + } else if (lower.startsWith(GEMINI_3_1_FLASH_PREFIX)) { + templateIds = GEMINI_3_1_FLASH_TEMPLATE_IDS; + } else { + return undefined; + } + + return cloneFirstTemplateModel({ + modelId: trimmed, + templateIds, + ctx, + }); +} + +export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: PROVIDER_LABEL, + docsPath: "/providers/models", + aliases: ["gemini-cli"], + envVars: ENV_VARS, + auth: [ + { + id: "oauth", + label: "Google OAuth", + hint: "PKCE + localhost callback", + kind: "oauth", + run: async (ctx: ProviderAuthContext) => { + const spin = ctx.prompter.progress("Starting Gemini CLI OAuth…"); + try { + const result = await loginGeminiCliOAuth({ + isRemote: ctx.isRemote, + openUrl: ctx.openUrl, + log: (msg) => ctx.runtime.log(msg), + note: ctx.prompter.note, + prompt: async (message) => String(await ctx.prompter.text({ message })), + progress: spin, + }); + + spin.stop("Gemini CLI OAuth complete"); + return buildOauthProviderAuthResult({ + providerId: PROVIDER_ID, + defaultModel: DEFAULT_MODEL, + access: result.access, + refresh: result.refresh, + expires: result.expires, + email: result.email, + credentialExtra: { projectId: result.projectId }, + notes: ["If requests fail, set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID."], + }); + } catch (err) { + spin.stop("Gemini CLI OAuth failed"); + await ctx.prompter.note( + "Trouble with OAuth? Ensure your Google account has Gemini CLI access.", + "OAuth help", + ); + throw err; + } + }, + }, + ], + resolveDynamicModel: (ctx) => resolveGeminiCliForwardCompatModel(ctx), + resolveUsageAuth: async (ctx) => { + const auth = await ctx.resolveOAuthToken(); + if (!auth) { + return null; + } + return { + ...auth, + token: parseGoogleUsageToken(auth.token), + }; + }, + fetchUsageSnapshot: async (ctx) => await fetchGeminiCliUsage(ctx), + }); +} diff --git a/extensions/google/index.ts b/extensions/google/index.ts index 5691137070b..806133b6419 100644 --- a/extensions/google/index.ts +++ b/extensions/google/index.ts @@ -5,6 +5,7 @@ import { } from "../../src/agents/tools/web-search-plugin-factory.js"; import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; import type { OpenClawPluginApi } from "../../src/plugins/types.js"; +import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js"; const googlePlugin = { id: "google", @@ -12,6 +13,7 @@ const googlePlugin = { description: "Bundled Google plugin", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { + registerGoogleGeminiCliProvider(api); api.registerWebSearchProvider( createPluginBackedWebSearchProvider({ id: "gemini", diff --git a/extensions/google-gemini-cli-auth/oauth.test.ts b/extensions/google/oauth.test.ts similarity index 99% rename from extensions/google-gemini-cli-auth/oauth.test.ts rename to extensions/google/oauth.test.ts index 02100b73b1f..8aec64d528d 100644 --- a/extensions/google-gemini-cli-auth/oauth.test.ts +++ b/extensions/google/oauth.test.ts @@ -1,8 +1,11 @@ import { join, parse } from "node:path"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -vi.mock("openclaw/plugin-sdk/google-gemini-cli-auth", () => ({ +vi.mock("../../src/infra/wsl.js", () => ({ isWSL2Sync: () => false, +})); + +vi.mock("../../src/infra/net/fetch-guard.js", () => ({ fetchWithSsrFGuard: async (params: { url: string; init?: RequestInit; diff --git a/extensions/google-gemini-cli-auth/oauth.ts b/extensions/google/oauth.ts similarity index 99% rename from extensions/google-gemini-cli-auth/oauth.ts rename to extensions/google/oauth.ts index 62881ec3a73..5932b3a237b 100644 --- a/extensions/google-gemini-cli-auth/oauth.ts +++ b/extensions/google/oauth.ts @@ -2,7 +2,8 @@ import { createHash, randomBytes } from "node:crypto"; import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; import { createServer } from "node:http"; import { delimiter, dirname, join } from "node:path"; -import { fetchWithSsrFGuard, isWSL2Sync } from "openclaw/plugin-sdk/google-gemini-cli-auth"; +import { fetchWithSsrFGuard } from "../../src/infra/net/fetch-guard.js"; +import { isWSL2Sync } from "../../src/infra/wsl.js"; const CLIENT_ID_KEYS = ["OPENCLAW_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"]; const CLIENT_SECRET_KEYS = [ diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index 40594e2f3f9..1a6d0dcd196 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -1,5 +1,6 @@ { "id": "google", + "providers": ["google-gemini-cli"], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/package.json b/package.json index 2fc0ec447d0..86822b23bf1 100644 --- a/package.json +++ b/package.json @@ -108,10 +108,6 @@ "types": "./dist/plugin-sdk/feishu.d.ts", "default": "./dist/plugin-sdk/feishu.js" }, - "./plugin-sdk/google-gemini-cli-auth": { - "types": "./dist/plugin-sdk/google-gemini-cli-auth.d.ts", - "default": "./dist/plugin-sdk/google-gemini-cli-auth.js" - }, "./plugin-sdk/googlechat": { "types": "./dist/plugin-sdk/googlechat.d.ts", "default": "./dist/plugin-sdk/googlechat.js" diff --git a/scripts/check-no-raw-channel-fetch.mjs b/scripts/check-no-raw-channel-fetch.mjs index 788585b8c54..7b935d183e5 100644 --- a/scripts/check-no-raw-channel-fetch.mjs +++ b/scripts/check-no-raw-channel-fetch.mjs @@ -14,11 +14,6 @@ const allowedRawFetchCallsites = new Set([ "extensions/feishu/src/streaming-card.ts:101", "extensions/feishu/src/streaming-card.ts:143", "extensions/feishu/src/streaming-card.ts:199", - "extensions/google-gemini-cli-auth/oauth.ts:372", - "extensions/google-gemini-cli-auth/oauth.ts:408", - "extensions/google-gemini-cli-auth/oauth.ts:447", - "extensions/google-gemini-cli-auth/oauth.ts:507", - "extensions/google-gemini-cli-auth/oauth.ts:575", "extensions/googlechat/src/api.ts:22", "extensions/googlechat/src/api.ts:43", "extensions/googlechat/src/api.ts:63", diff --git a/scripts/check-plugin-sdk-exports.mjs b/scripts/check-plugin-sdk-exports.mjs index 03ff9dfde8f..93fc3fcb545 100755 --- a/scripts/check-plugin-sdk-exports.mjs +++ b/scripts/check-plugin-sdk-exports.mjs @@ -59,7 +59,6 @@ const requiredSubpathEntries = [ "diagnostics-otel", "diffs", "feishu", - "google-gemini-cli-auth", "googlechat", "irc", "llm-task", diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 34d37634d6f..b8e4fa6706b 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -57,8 +57,6 @@ const requiredPathGroups = [ "dist/plugin-sdk/diffs.d.ts", "dist/plugin-sdk/feishu.js", "dist/plugin-sdk/feishu.d.ts", - "dist/plugin-sdk/google-gemini-cli-auth.js", - "dist/plugin-sdk/google-gemini-cli-auth.d.ts", "dist/plugin-sdk/googlechat.js", "dist/plugin-sdk/googlechat.d.ts", "dist/plugin-sdk/irc.js", diff --git a/scripts/write-plugin-sdk-entry-dts.ts b/scripts/write-plugin-sdk-entry-dts.ts index beb5db5481b..d0331377432 100644 --- a/scripts/write-plugin-sdk-entry-dts.ts +++ b/scripts/write-plugin-sdk-entry-dts.ts @@ -25,7 +25,6 @@ const entrypoints = [ "diagnostics-otel", "diffs", "feishu", - "google-gemini-cli-auth", "googlechat", "irc", "llm-task", diff --git a/src/commands/auth-choice.apply.google-gemini-cli.test.ts b/src/commands/auth-choice.apply.google-gemini-cli.test.ts index f07f970a18d..50a17014908 100644 --- a/src/commands/auth-choice.apply.google-gemini-cli.test.ts +++ b/src/commands/auth-choice.apply.google-gemini-cli.test.ts @@ -77,7 +77,7 @@ describe("applyAuthChoiceGoogleGeminiCli", () => { expect(result).toBe(expected); expect(mockedApplyAuthChoicePluginProvider).toHaveBeenCalledWith(params, { authChoice: "google-gemini-cli", - pluginId: "google-gemini-cli-auth", + pluginId: "google", providerId: "google-gemini-cli", methodId: "oauth", label: "Google Gemini CLI", diff --git a/src/commands/auth-choice.apply.google-gemini-cli.ts b/src/commands/auth-choice.apply.google-gemini-cli.ts index 5fcbc832338..e2aa1d02398 100644 --- a/src/commands/auth-choice.apply.google-gemini-cli.ts +++ b/src/commands/auth-choice.apply.google-gemini-cli.ts @@ -29,7 +29,7 @@ export async function applyAuthChoiceGoogleGeminiCli( return await applyAuthChoicePluginProvider(params, { authChoice: "google-gemini-cli", - pluginId: "google-gemini-cli-auth", + pluginId: "google", providerId: "google-gemini-cli", methodId: "oauth", label: "Google Gemini CLI", diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index c289417ce53..cae9b4e5c18 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -307,7 +307,7 @@ describe("applyPluginAutoEnable", () => { env: {}, }); - expect(result.config.plugins?.entries?.["google-gemini-cli-auth"]?.enabled).toBe(true); + expect(result.config.plugins?.entries?.google?.enabled).toBe(true); }); it("auto-enables acpx plugin when ACP is configured", () => { diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 4e0cae1209f..72e1dede1ef 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -28,7 +28,7 @@ export type PluginAutoEnableResult = { }; const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [ - { pluginId: "google-gemini-cli-auth", providerId: "google-gemini-cli" }, + { pluginId: "google", providerId: "google-gemini-cli" }, { pluginId: "qwen-portal-auth", providerId: "qwen-portal" }, { pluginId: "copilot-proxy", providerId: "copilot-proxy" }, { pluginId: "minimax-portal-auth", providerId: "minimax-portal" }, diff --git a/src/plugin-sdk/google-gemini-cli-auth.ts b/src/plugin-sdk/google-gemini-cli-auth.ts deleted file mode 100644 index a03002feaab..00000000000 --- a/src/plugin-sdk/google-gemini-cli-auth.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Narrow plugin-sdk surface for the bundled google-gemini-cli-auth plugin. -// Keep this list additive and scoped to symbols used under extensions/google-gemini-cli-auth. - -export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; -export { isWSL2Sync } from "../infra/wsl.js"; -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; -export type { - OpenClawPluginApi, - ProviderAuthContext, - ProviderFetchUsageSnapshotContext, - ProviderResolveDynamicModelContext, - ProviderRuntimeModel, -} from "../plugins/types.js"; -export type { ProviderUsageSnapshot } from "../infra/provider-usage.types.js"; -export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts index 61d1cccb10c..8fe13972e11 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.ts @@ -25,7 +25,6 @@ const pluginSdkEntrypoints = [ "diagnostics-otel", "diffs", "feishu", - "google-gemini-cli-auth", "googlechat", "irc", "llm-task", diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 996c6b27188..09341c4e82b 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -18,10 +18,6 @@ const bundledExtensionSubpathLoaders = [ { id: "diagnostics-otel", load: () => import("openclaw/plugin-sdk/diagnostics-otel") }, { id: "diffs", load: () => import("openclaw/plugin-sdk/diffs") }, { id: "feishu", load: () => import("openclaw/plugin-sdk/feishu") }, - { - id: "google-gemini-cli-auth", - load: () => import("openclaw/plugin-sdk/google-gemini-cli-auth"), - }, { id: "googlechat", load: () => import("openclaw/plugin-sdk/googlechat") }, { id: "irc", load: () => import("openclaw/plugin-sdk/irc") }, { id: "llm-task", load: () => import("openclaw/plugin-sdk/llm-task") }, diff --git a/src/plugins/enable.test.ts b/src/plugins/enable.test.ts index 793ed1c7ffe..89259b8a583 100644 --- a/src/plugins/enable.test.ts +++ b/src/plugins/enable.test.ts @@ -5,9 +5,9 @@ import { enablePluginInConfig } from "./enable.js"; describe("enablePluginInConfig", () => { it("enables a plugin entry", () => { const cfg: OpenClawConfig = {}; - const result = enablePluginInConfig(cfg, "google-gemini-cli-auth"); + const result = enablePluginInConfig(cfg, "google"); expect(result.enabled).toBe(true); - expect(result.config.plugins?.entries?.["google-gemini-cli-auth"]?.enabled).toBe(true); + expect(result.config.plugins?.entries?.google?.enabled).toBe(true); }); it("adds plugin to allowlist when allowlist is configured", () => { @@ -16,18 +16,18 @@ describe("enablePluginInConfig", () => { allow: ["memory-core"], }, }; - const result = enablePluginInConfig(cfg, "google-gemini-cli-auth"); + const result = enablePluginInConfig(cfg, "google"); expect(result.enabled).toBe(true); - expect(result.config.plugins?.allow).toEqual(["memory-core", "google-gemini-cli-auth"]); + expect(result.config.plugins?.allow).toEqual(["memory-core", "google"]); }); it("refuses enable when plugin is denylisted", () => { const cfg: OpenClawConfig = { plugins: { - deny: ["google-gemini-cli-auth"], + deny: ["google"], }, }; - const result = enablePluginInConfig(cfg, "google-gemini-cli-auth"); + const result = enablePluginInConfig(cfg, "google"); expect(result.enabled).toBe(false); expect(result.reason).toBe("blocked by denylist"); }); diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 68b83561461..7e18664067b 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -10,7 +10,7 @@ const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "cloudflare-ai-gateway", "copilot-proxy", "github-copilot", - "google-gemini-cli-auth", + "google", "huggingface", "kilocode", "kimi-coding", diff --git a/tsconfig.plugin-sdk.dts.json b/tsconfig.plugin-sdk.dts.json index f938dcc8262..15828b8b7ad 100644 --- a/tsconfig.plugin-sdk.dts.json +++ b/tsconfig.plugin-sdk.dts.json @@ -31,7 +31,6 @@ "src/plugin-sdk/diagnostics-otel.ts", "src/plugin-sdk/diffs.ts", "src/plugin-sdk/feishu.ts", - "src/plugin-sdk/google-gemini-cli-auth.ts", "src/plugin-sdk/googlechat.ts", "src/plugin-sdk/irc.ts", "src/plugin-sdk/llm-task.ts", diff --git a/tsdown.config.ts b/tsdown.config.ts index 6ed9ccb930b..2b7c9dbe192 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -77,7 +77,6 @@ const pluginSdkEntrypoints = [ "diagnostics-otel", "diffs", "feishu", - "google-gemini-cli-auth", "googlechat", "irc", "llm-task", diff --git a/vitest.config.ts b/vitest.config.ts index 70011a6a0b8..c45f5f45c25 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -27,7 +27,6 @@ const pluginSdkSubpaths = [ "diagnostics-otel", "diffs", "feishu", - "google-gemini-cli-auth", "googlechat", "irc", "llm-task", From b54e37c71f4d3dda7ed2a4024dd28dbba3f9641c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 17:50:16 -0700 Subject: [PATCH 088/943] feat(plugins): merge openai vendor seams into one plugin --- docs/concepts/model-providers.md | 19 +- docs/tools/plugin.md | 45 +++-- extensions/openai-codex/openclaw.plugin.json | 9 - extensions/openai-codex/package.json | 12 -- extensions/openai/index.test.ts | 41 ++++- extensions/openai/index.ts | 135 +-------------- .../openai-codex-provider.ts} | 162 ++++++++---------- .../openai-codex.test.ts} | 18 +- extensions/openai/openai-provider.ts | 143 ++++++++++++++++ extensions/openai/openclaw.plugin.json | 2 +- extensions/openai/package.json | 2 +- extensions/openai/shared.ts | 57 ++++++ src/agents/model-auth.ts | 21 ++- src/agents/model-catalog.ts | 89 +++------- src/agents/model-forward-compat.ts | 158 +---------------- src/agents/model-suppression.ts | 31 ++-- src/agents/pi-embedded-runner/model.ts | 2 +- src/plugin-sdk/core.ts | 4 + src/plugin-sdk/index.ts | 4 + src/plugins/config-state.test.ts | 16 ++ src/plugins/config-state.ts | 29 +++- src/plugins/provider-runtime.test.ts | 134 ++++++++++++--- src/plugins/provider-runtime.ts | 81 ++++++++- src/plugins/providers.test.ts | 18 ++ src/plugins/providers.ts | 61 ++++++- src/plugins/types.ts | 88 ++++++++++ 26 files changed, 833 insertions(+), 548 deletions(-) delete mode 100644 extensions/openai-codex/openclaw.plugin.json delete mode 100644 extensions/openai-codex/package.json rename extensions/{openai-codex/index.ts => openai/openai-codex-provider.ts} (59%) rename extensions/{openai-codex/index.test.ts => openai/openai-codex.test.ts} (87%) create mode 100644 extensions/openai/openai-provider.ts create mode 100644 extensions/openai/shared.ts diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index d20b5055763..23fe7edcd1d 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -22,8 +22,9 @@ For model selection rules, see [/concepts/models](/concepts/models). - Provider plugins can also own provider runtime behavior via `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, - `isCacheTtlEligible`, `prepareRuntimeAuth`, `resolveUsageAuth`, and - `fetchUsageSnapshot`. + `isCacheTtlEligible`, `buildMissingAuthMessage`, + `suppressBuiltInModel`, `augmentModelCatalog`, `prepareRuntimeAuth`, + `resolveUsageAuth`, and `fetchUsageSnapshot`. ## Plugin-owned provider behavior @@ -42,6 +43,12 @@ Typical split: - `prepareExtraParams`: provider defaults or normalizes per-model request params - `wrapStreamFn`: provider applies request headers/body/model compat wrappers - `isCacheTtlEligible`: provider decides which upstream model ids support prompt-cache TTL +- `buildMissingAuthMessage`: provider replaces the generic auth-store error + with a provider-specific recovery hint +- `suppressBuiltInModel`: provider hides stale upstream rows and can return a + vendor-owned error for direct resolution failures +- `augmentModelCatalog`: provider appends synthetic/final catalog rows after + discovery and config merging - `prepareRuntimeAuth`: provider turns a configured credential into a short lived runtime token - `resolveUsageAuth`: provider resolves usage/quota credentials for `/usage` @@ -58,9 +65,8 @@ Current bundled examples: - `github-copilot`: forward-compat model fallback, Claude-thinking transcript hints, runtime token exchange, and usage endpoint fetching - `openai`: GPT-5.4 forward-compat fallback, direct OpenAI transport - normalization, and provider-family metadata -- `openai-codex`: forward-compat model fallback, transport normalization, and - default transport params plus usage endpoint fetching + normalization, Codex-aware missing-auth hints, Spark suppression, synthetic + OpenAI/Codex catalog rows, and provider-family metadata - `google-gemini-cli`: Gemini 3.1 forward-compat fallback plus usage-token parsing and quota endpoint fetching for usage surfaces - `moonshot`: shared transport, plugin-owned thinking payload normalization @@ -75,6 +81,9 @@ Current bundled examples: plugin-owned catalogs only - `minimax` and `xiaomi`: plugin-owned catalogs plus usage auth/snapshot logic +The bundled `openai` plugin now owns both provider ids: `openai` and +`openai-codex`. + That covers providers that still fit OpenClaw's normal transports. A provider that needs a totally custom request executor is a separate, deeper extension surface. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 59752ddf253..1cfe6ae1cd0 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -178,8 +178,7 @@ Important trust note: - Model Studio provider catalog — bundled as `modelstudio` (enabled by default) - Moonshot provider runtime — bundled as `moonshot` (enabled by default) - NVIDIA provider catalog — bundled as `nvidia` (enabled by default) -- OpenAI provider runtime — bundled as `openai` (enabled by default) -- OpenAI Codex provider runtime — bundled as `openai-codex` (enabled by default) +- OpenAI provider runtime — bundled as `openai` (enabled by default; owns both `openai` and `openai-codex`) - OpenCode Go provider capabilities — bundled as `opencode-go` (enabled by default) - OpenCode Zen provider capabilities — bundled as `opencode` (enabled by default) - OpenRouter provider runtime — bundled as `openrouter` (enabled by default) @@ -207,7 +206,7 @@ Native OpenClaw plugins can register: - Background services - Context engines - Provider auth flows and model catalogs -- Provider runtime hooks for dynamic model ids, transport normalization, capability metadata, stream wrapping, cache TTL policy, runtime auth exchange, and usage/billing auth + snapshot resolution +- Provider runtime hooks for dynamic model ids, transport normalization, capability metadata, stream wrapping, cache TTL policy, missing-auth hints, built-in model suppression, catalog augmentation, runtime auth exchange, and usage/billing auth + snapshot resolution - Optional config validation - **Skills** (by listing `skills` directories in the plugin manifest) - **Auto-reply commands** (execute without invoking the AI agent) @@ -220,7 +219,7 @@ Tool authoring guide: [Plugin agent tools](/plugins/agent-tools). Provider plugins now have two layers: - config-time hooks: `catalog` / legacy `discovery` -- runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `isCacheTtlEligible`, `prepareRuntimeAuth`, `resolveUsageAuth`, `fetchUsageSnapshot` +- runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `isCacheTtlEligible`, `buildMissingAuthMessage`, `suppressBuiltInModel`, `augmentModelCatalog`, `prepareRuntimeAuth`, `resolveUsageAuth`, `fetchUsageSnapshot` OpenClaw still owns the generic agent loop, failover, transcript handling, and tool policy. These hooks are the seam for provider-specific behavior without @@ -251,13 +250,20 @@ For model/provider plugins, OpenClaw uses hooks in this rough order: Provider-owned stream wrapper after generic wrappers are applied. 9. `isCacheTtlEligible` Provider-owned prompt-cache policy for proxy/backhaul providers. -10. `prepareRuntimeAuth` +10. `buildMissingAuthMessage` + Provider-owned replacement for the generic missing-auth recovery message. +11. `suppressBuiltInModel` + Provider-owned stale upstream model suppression plus optional user-facing + error hint. +12. `augmentModelCatalog` + Provider-owned synthetic/final catalog rows appended after discovery. +13. `prepareRuntimeAuth` Exchanges a configured credential into the actual runtime token/key just before inference. -11. `resolveUsageAuth` +14. `resolveUsageAuth` Resolves usage/billing credentials for `/usage` and related status surfaces. -12. `fetchUsageSnapshot` +15. `fetchUsageSnapshot` Fetches and normalizes provider-specific usage/quota snapshots after auth is resolved. @@ -271,6 +277,9 @@ For model/provider plugins, OpenClaw uses hooks in this rough order: - `prepareExtraParams`: set provider defaults or normalize provider-specific per-model params before generic stream wrapping - `wrapStreamFn`: add provider-specific headers/payload/model compat patches while still using the normal `pi-ai` execution path - `isCacheTtlEligible`: decide whether provider/model pairs should use cache TTL metadata +- `buildMissingAuthMessage`: replace the generic auth-store error with a provider-specific recovery hint +- `suppressBuiltInModel`: hide stale upstream rows and optionally return a provider-owned error for direct resolution failures +- `augmentModelCatalog`: append synthetic/final catalog rows after discovery and config merging - `prepareRuntimeAuth`: exchange a configured credential into the actual short-lived runtime token/key used for requests - `resolveUsageAuth`: resolve provider-owned credentials for usage/billing endpoints without hardcoding token parsing in core - `fetchUsageSnapshot`: own provider-specific usage endpoint fetch/parsing while core keeps summary fan-out and formatting @@ -285,6 +294,9 @@ Rule of thumb: - provider needs default request params or per-provider param cleanup: use `prepareExtraParams` - provider needs request headers/body/model compat wrappers without a custom transport: use `wrapStreamFn` - provider needs proxy-specific cache TTL gating: use `isCacheTtlEligible` +- provider needs a provider-specific missing-auth recovery hint: use `buildMissingAuthMessage` +- provider needs to hide stale upstream rows or replace them with a vendor hint: use `suppressBuiltInModel` +- provider needs synthetic forward-compat rows in `models list` and pickers: use `augmentModelCatalog` - provider needs a token exchange or short-lived request credential: use `prepareRuntimeAuth` - provider needs custom usage/quota token parsing or a different usage credential: use `resolveUsageAuth` - provider needs a provider-specific usage endpoint or payload parser: use `fetchUsageSnapshot` @@ -354,8 +366,10 @@ api.registerProvider({ forward-compat, provider-family hints, usage endpoint integration, and prompt-cache eligibility. - OpenAI uses `resolveDynamicModel`, `normalizeResolvedModel`, and - `capabilities` because it owns GPT-5.4 forward-compat plus the direct OpenAI - `openai-completions` -> `openai-responses` normalization. + `capabilities` plus `buildMissingAuthMessage`, `suppressBuiltInModel`, and + `augmentModelCatalog` because it owns GPT-5.4 forward-compat, the direct + OpenAI `openai-completions` -> `openai-responses` normalization, Codex-aware + auth hints, Spark suppression, and synthetic OpenAI list rows. - OpenRouter uses `catalog` plus `resolveDynamicModel` and `prepareDynamicModel` because the provider is pass-through and may expose new model ids before OpenClaw's static catalog updates. @@ -363,11 +377,12 @@ api.registerProvider({ `capabilities` plus `prepareRuntimeAuth` and `fetchUsageSnapshot` because it needs model fallback behavior, Claude transcript quirks, a GitHub token -> Copilot token exchange, and a provider-owned usage endpoint. -- OpenAI Codex uses `catalog`, `resolveDynamicModel`, and - `normalizeResolvedModel` plus `prepareExtraParams`, `resolveUsageAuth`, and - `fetchUsageSnapshot` because it still runs on core OpenAI transports but owns - its transport/base URL normalization, default transport choice, and ChatGPT - usage endpoint integration. +- OpenAI Codex uses `catalog`, `resolveDynamicModel`, + `normalizeResolvedModel`, and `augmentModelCatalog` plus + `prepareExtraParams`, `resolveUsageAuth`, and `fetchUsageSnapshot` because it + still runs on core OpenAI transports but owns its transport/base URL + normalization, default transport choice, synthetic Codex catalog rows, and + ChatGPT usage endpoint integration. - Gemini CLI OAuth uses `resolveDynamicModel`, `resolveUsageAuth`, and `fetchUsageSnapshot` because it owns Gemini 3.1 forward-compat fallback plus the token parsing and quota endpoint wiring needed by `/usage`. @@ -654,7 +669,7 @@ Default-on bundled plugin examples: - `moonshot` - `nvidia` - `ollama` -- `openai-codex` +- `openai` - `openrouter` - `phone-control` - `qianfan` diff --git a/extensions/openai-codex/openclaw.plugin.json b/extensions/openai-codex/openclaw.plugin.json deleted file mode 100644 index 0dfd4106a9a..00000000000 --- a/extensions/openai-codex/openclaw.plugin.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": "openai-codex", - "providers": ["openai-codex"], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": {} - } -} diff --git a/extensions/openai-codex/package.json b/extensions/openai-codex/package.json deleted file mode 100644 index 49730240ff8..00000000000 --- a/extensions/openai-codex/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@openclaw/openai-codex-provider", - "version": "2026.3.14", - "private": true, - "description": "OpenClaw OpenAI Codex provider plugin", - "type": "module", - "openclaw": { - "extensions": [ - "./index.ts" - ] - } -} diff --git a/extensions/openai/index.test.ts b/extensions/openai/index.test.ts index cdf2d1f8a27..32b5b4b3a63 100644 --- a/extensions/openai/index.test.ts +++ b/extensions/openai/index.test.ts @@ -2,22 +2,31 @@ import { describe, expect, it } from "vitest"; import type { ProviderPlugin } from "../../src/plugins/types.js"; import openAIPlugin from "./index.js"; -function registerProvider(): ProviderPlugin { - let provider: ProviderPlugin | undefined; +function registerProviders(): ProviderPlugin[] { + const providers: ProviderPlugin[] = []; openAIPlugin.register({ registerProvider(nextProvider: ProviderPlugin) { - provider = nextProvider; + providers.push(nextProvider); }, } as never); + return providers; +} + +function requireProvider(id: string): ProviderPlugin { + const provider = registerProviders().find((entry) => entry.id === id); if (!provider) { - throw new Error("provider registration missing"); + throw new Error(`provider registration missing for ${id}`); } return provider; } describe("openai plugin", () => { + it("registers openai and openai-codex providers from one extension", () => { + expect(registerProviders().map((provider) => provider.id)).toEqual(["openai", "openai-codex"]); + }); + it("owns openai gpt-5.4 forward-compat resolution", () => { - const provider = registerProvider(); + const provider = requireProvider("openai"); const model = provider.resolveDynamicModel?.({ provider: "openai", modelId: "gpt-5.4-pro", @@ -51,7 +60,7 @@ describe("openai plugin", () => { }); it("owns direct openai transport normalization", () => { - const provider = registerProvider(); + const provider = requireProvider("openai"); expect( provider.normalizeResolvedModel?.({ provider: "openai", @@ -73,4 +82,24 @@ describe("openai plugin", () => { api: "openai-responses", }); }); + + it("owns codex-only missing-auth hints and Spark suppression", () => { + const provider = requireProvider("openai"); + expect( + provider.buildMissingAuthMessage?.({ + env: {} as NodeJS.ProcessEnv, + provider: "openai", + listProfileIds: (providerId) => (providerId === "openai-codex" ? ["p1"] : []), + }), + ).toContain("openai-codex/gpt-5.4"); + expect( + provider.suppressBuiltInModel?.({ + env: {} as NodeJS.ProcessEnv, + provider: "azure-openai-responses", + modelId: "gpt-5.3-codex-spark", + }), + ).toMatchObject({ + suppress: true, + }); + }); }); diff --git a/extensions/openai/index.ts b/extensions/openai/index.ts index cc2ca6fe4a0..3a01aad8db9 100644 --- a/extensions/openai/index.ts +++ b/extensions/openai/index.ts @@ -1,136 +1,15 @@ -import { - emptyPluginConfigSchema, - type OpenClawPluginApi, - type ProviderResolveDynamicModelContext, - type ProviderRuntimeModel, -} from "openclaw/plugin-sdk/core"; -import { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.js"; -import { normalizeModelCompat } from "../../src/agents/model-compat.js"; -import { normalizeProviderId } from "../../src/agents/model-selection.js"; - -const PROVIDER_ID = "openai"; -const OPENAI_BASE_URL = "https://api.openai.com/v1"; -const OPENAI_GPT_54_MODEL_ID = "gpt-5.4"; -const OPENAI_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro"; -const OPENAI_GPT_54_CONTEXT_TOKENS = 1_050_000; -const OPENAI_GPT_54_MAX_TOKENS = 128_000; -const OPENAI_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.2"] as const; -const OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS = ["gpt-5.2-pro", "gpt-5.2"] as const; - -function isOpenAIApiBaseUrl(baseUrl?: string): boolean { - const trimmed = baseUrl?.trim(); - if (!trimmed) { - return false; - } - return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/i.test(trimmed); -} - -function normalizeOpenAITransport(model: ProviderRuntimeModel): ProviderRuntimeModel { - const useResponsesTransport = - model.api === "openai-completions" && (!model.baseUrl || isOpenAIApiBaseUrl(model.baseUrl)); - - if (!useResponsesTransport) { - return model; - } - - return { - ...model, - api: "openai-responses", - }; -} - -function cloneFirstTemplateModel(params: { - modelId: string; - templateIds: readonly string[]; - ctx: ProviderResolveDynamicModelContext; - patch?: Partial; -}): ProviderRuntimeModel | undefined { - const trimmedModelId = params.modelId.trim(); - for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { - const template = params.ctx.modelRegistry.find( - PROVIDER_ID, - templateId, - ) as ProviderRuntimeModel | null; - if (!template) { - continue; - } - return normalizeModelCompat({ - ...template, - id: trimmedModelId, - name: trimmedModelId, - ...params.patch, - } as ProviderRuntimeModel); - } - return undefined; -} - -function resolveOpenAIGpt54ForwardCompatModel( - ctx: ProviderResolveDynamicModelContext, -): ProviderRuntimeModel | undefined { - const trimmedModelId = ctx.modelId.trim(); - const lower = trimmedModelId.toLowerCase(); - let templateIds: readonly string[]; - if (lower === OPENAI_GPT_54_MODEL_ID) { - templateIds = OPENAI_GPT_54_TEMPLATE_MODEL_IDS; - } else if (lower === OPENAI_GPT_54_PRO_MODEL_ID) { - templateIds = OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS; - } else { - return undefined; - } - - return ( - cloneFirstTemplateModel({ - modelId: trimmedModelId, - templateIds, - ctx, - patch: { - api: "openai-responses", - provider: PROVIDER_ID, - baseUrl: OPENAI_BASE_URL, - reasoning: true, - input: ["text", "image"], - contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, - maxTokens: OPENAI_GPT_54_MAX_TOKENS, - }, - }) ?? - normalizeModelCompat({ - id: trimmedModelId, - name: trimmedModelId, - api: "openai-responses", - provider: PROVIDER_ID, - baseUrl: OPENAI_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, - maxTokens: OPENAI_GPT_54_MAX_TOKENS, - } as ProviderRuntimeModel) - ); -} +import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js"; +import { buildOpenAIProvider } from "./openai-provider.js"; const openAIPlugin = { - id: PROVIDER_ID, + id: "openai", name: "OpenAI Provider", - description: "Bundled OpenAI provider plugin", + description: "Bundled OpenAI provider plugins", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { - api.registerProvider({ - id: PROVIDER_ID, - label: "OpenAI", - docsPath: "/providers/models", - envVars: ["OPENAI_API_KEY"], - auth: [], - resolveDynamicModel: (ctx) => resolveOpenAIGpt54ForwardCompatModel(ctx), - normalizeResolvedModel: (ctx) => { - if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) { - return undefined; - } - return normalizeOpenAITransport(ctx.model); - }, - capabilities: { - providerFamily: "openai", - }, - }); + api.registerProvider(buildOpenAIProvider()); + api.registerProvider(buildOpenAICodexProviderPlugin()); }, }; diff --git a/extensions/openai-codex/index.ts b/extensions/openai/openai-codex-provider.ts similarity index 59% rename from extensions/openai-codex/index.ts rename to extensions/openai/openai-codex-provider.ts index 9d8ee0769af..af5f85d4d21 100644 --- a/extensions/openai-codex/index.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -1,8 +1,6 @@ -import { - emptyPluginConfigSchema, - type OpenClawPluginApi, - type ProviderResolveDynamicModelContext, - type ProviderRuntimeModel, +import type { + ProviderResolveDynamicModelContext, + ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; import { listProfilesForProvider } from "../../src/agents/auth-profiles/profiles.js"; import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js"; @@ -11,6 +9,8 @@ import { normalizeModelCompat } from "../../src/agents/model-compat.js"; import { normalizeProviderId } from "../../src/agents/model-selection.js"; import { buildOpenAICodexProvider } from "../../src/agents/models-config.providers.static.js"; import { fetchCodexUsage } from "../../src/infra/provider-usage.fetch.js"; +import type { ProviderPlugin } from "../../src/plugins/types.js"; +import { cloneFirstTemplateModel, findCatalogTemplate, isOpenAIApiBaseUrl } from "./shared.js"; const PROVIDER_ID = "openai-codex"; const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; @@ -24,14 +24,6 @@ const OPENAI_CODEX_GPT_53_SPARK_CONTEXT_TOKENS = 128_000; const OPENAI_CODEX_GPT_53_SPARK_MAX_TOKENS = 128_000; const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const; -function isOpenAIApiBaseUrl(baseUrl?: string): boolean { - const trimmed = baseUrl?.trim(); - if (!trimmed) { - return false; - } - return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/i.test(trimmed); -} - function isOpenAICodexBaseUrl(baseUrl?: string): boolean { const trimmed = baseUrl?.trim(); if (!trimmed) { @@ -59,31 +51,6 @@ function normalizeCodexTransport(model: ProviderRuntimeModel): ProviderRuntimeMo }; } -function cloneFirstTemplateModel(params: { - modelId: string; - templateIds: readonly string[]; - ctx: ProviderResolveDynamicModelContext; - patch?: Partial; -}): ProviderRuntimeModel | undefined { - const trimmedModelId = params.modelId.trim(); - for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { - const template = params.ctx.modelRegistry.find( - PROVIDER_ID, - templateId, - ) as ProviderRuntimeModel | null; - if (!template) { - continue; - } - return normalizeModelCompat({ - ...template, - id: trimmedModelId, - name: trimmedModelId, - ...params.patch, - } as ProviderRuntimeModel); - } - return undefined; -} - function resolveCodexForwardCompatModel( ctx: ProviderResolveDynamicModelContext, ): ProviderRuntimeModel | undefined { @@ -118,6 +85,7 @@ function resolveCodexForwardCompatModel( return ( cloneFirstTemplateModel({ + providerId: PROVIDER_ID, modelId: trimmedModelId, templateIds, ctx, @@ -138,56 +106,76 @@ function resolveCodexForwardCompatModel( ); } -const openAICodexPlugin = { - id: "openai-codex", - name: "OpenAI Codex Provider", - description: "Bundled OpenAI Codex provider plugin", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { - api.registerProvider({ - id: PROVIDER_ID, - label: "OpenAI Codex", - docsPath: "/providers/models", - auth: [], - catalog: { - order: "profile", - run: async (ctx) => { - const authStore = ensureAuthProfileStore(ctx.agentDir, { - allowKeychainPrompt: false, - }); - if (listProfilesForProvider(authStore, PROVIDER_ID).length === 0) { - return null; - } - return { - provider: buildOpenAICodexProvider(), - }; - }, - }, - resolveDynamicModel: (ctx) => resolveCodexForwardCompatModel(ctx), - capabilities: { - providerFamily: "openai", - }, - prepareExtraParams: (ctx) => { - const transport = ctx.extraParams?.transport; - if (transport === "auto" || transport === "sse" || transport === "websocket") { - return ctx.extraParams; +export function buildOpenAICodexProviderPlugin(): ProviderPlugin { + return { + id: PROVIDER_ID, + label: "OpenAI Codex", + docsPath: "/providers/models", + auth: [], + catalog: { + order: "profile", + run: async (ctx) => { + const authStore = ensureAuthProfileStore(ctx.agentDir, { + allowKeychainPrompt: false, + }); + if (listProfilesForProvider(authStore, PROVIDER_ID).length === 0) { + return null; } return { - ...ctx.extraParams, - transport: "auto", + provider: buildOpenAICodexProvider(), }; }, - normalizeResolvedModel: (ctx) => { - if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) { - return undefined; - } - return normalizeCodexTransport(ctx.model); - }, - resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(), - fetchUsageSnapshot: async (ctx) => - await fetchCodexUsage(ctx.token, ctx.accountId, ctx.timeoutMs, ctx.fetchFn), - }); - }, -}; - -export default openAICodexPlugin; + }, + resolveDynamicModel: (ctx) => resolveCodexForwardCompatModel(ctx), + capabilities: { + providerFamily: "openai", + }, + prepareExtraParams: (ctx) => { + const transport = ctx.extraParams?.transport; + if (transport === "auto" || transport === "sse" || transport === "websocket") { + return ctx.extraParams; + } + return { + ...ctx.extraParams, + transport: "auto", + }; + }, + normalizeResolvedModel: (ctx) => { + if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) { + return undefined; + } + return normalizeCodexTransport(ctx.model); + }, + resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(), + fetchUsageSnapshot: async (ctx) => + await fetchCodexUsage(ctx.token, ctx.accountId, ctx.timeoutMs, ctx.fetchFn), + augmentModelCatalog: (ctx) => { + const gpt54Template = findCatalogTemplate({ + entries: ctx.entries, + providerId: PROVIDER_ID, + templateIds: OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS, + }); + const sparkTemplate = findCatalogTemplate({ + entries: ctx.entries, + providerId: PROVIDER_ID, + templateIds: [OPENAI_CODEX_GPT_53_MODEL_ID, ...OPENAI_CODEX_TEMPLATE_MODEL_IDS], + }); + return [ + gpt54Template + ? { + ...gpt54Template, + id: OPENAI_CODEX_GPT_54_MODEL_ID, + name: OPENAI_CODEX_GPT_54_MODEL_ID, + } + : undefined, + sparkTemplate + ? { + ...sparkTemplate, + id: OPENAI_CODEX_GPT_53_SPARK_MODEL_ID, + name: OPENAI_CODEX_GPT_53_SPARK_MODEL_ID, + } + : undefined, + ].filter((entry): entry is NonNullable => entry !== undefined); + }, + }; +} diff --git a/extensions/openai-codex/index.test.ts b/extensions/openai/openai-codex.test.ts similarity index 87% rename from extensions/openai-codex/index.test.ts rename to extensions/openai/openai-codex.test.ts index 53bbd700f17..bbf77320b26 100644 --- a/extensions/openai-codex/index.test.ts +++ b/extensions/openai/openai-codex.test.ts @@ -4,13 +4,15 @@ import { createProviderUsageFetch, makeResponse, } from "../../src/test-utils/provider-usage-fetch.js"; -import openAICodexPlugin from "./index.js"; +import openAIPlugin from "./index.js"; -function registerProvider(): ProviderPlugin { +function registerCodexProvider(): ProviderPlugin { let provider: ProviderPlugin | undefined; - openAICodexPlugin.register({ + openAIPlugin.register({ registerProvider(nextProvider: ProviderPlugin) { - provider = nextProvider; + if (nextProvider.id === "openai-codex") { + provider = nextProvider; + } }, } as never); if (!provider) { @@ -19,9 +21,9 @@ function registerProvider(): ProviderPlugin { return provider; } -describe("openai-codex plugin", () => { +describe("openai codex provider", () => { it("owns forward-compat codex models", () => { - const provider = registerProvider(); + const provider = registerCodexProvider(); const model = provider.resolveDynamicModel?.({ provider: "openai-codex", modelId: "gpt-5.4", @@ -54,7 +56,7 @@ describe("openai-codex plugin", () => { }); it("owns codex transport defaults", () => { - const provider = registerProvider(); + const provider = registerCodexProvider(); expect( provider.prepareExtraParams?.({ provider: "openai-codex", @@ -68,7 +70,7 @@ describe("openai-codex plugin", () => { }); it("owns usage snapshot fetching", async () => { - const provider = registerProvider(); + const provider = registerCodexProvider(); const mockFetch = createProviderUsageFetch(async (url) => { if (url.includes("chatgpt.com/backend-api/wham/usage")) { return makeResponse(200, { diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts new file mode 100644 index 00000000000..9ce61e2a2b8 --- /dev/null +++ b/extensions/openai/openai-provider.ts @@ -0,0 +1,143 @@ +import { + type ProviderResolveDynamicModelContext, + type ProviderRuntimeModel, +} from "openclaw/plugin-sdk/core"; +import { normalizeModelCompat } from "../../src/agents/model-compat.js"; +import { normalizeProviderId } from "../../src/agents/model-selection.js"; +import type { ProviderPlugin } from "../../src/plugins/types.js"; +import { cloneFirstTemplateModel, findCatalogTemplate, isOpenAIApiBaseUrl } from "./shared.js"; + +const PROVIDER_ID = "openai"; +const OPENAI_GPT_54_MODEL_ID = "gpt-5.4"; +const OPENAI_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro"; +const OPENAI_GPT_54_CONTEXT_TOKENS = 1_050_000; +const OPENAI_GPT_54_MAX_TOKENS = 128_000; +const OPENAI_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.2"] as const; +const OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS = ["gpt-5.2-pro", "gpt-5.2"] as const; +const OPENAI_DIRECT_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; +const SUPPRESSED_SPARK_PROVIDERS = new Set(["openai", "azure-openai-responses"]); + +function normalizeOpenAITransport(model: ProviderRuntimeModel): ProviderRuntimeModel { + const useResponsesTransport = + model.api === "openai-completions" && (!model.baseUrl || isOpenAIApiBaseUrl(model.baseUrl)); + + if (!useResponsesTransport) { + return model; + } + + return { + ...model, + api: "openai-responses", + }; +} + +function resolveOpenAIGpt54ForwardCompatModel( + ctx: ProviderResolveDynamicModelContext, +): ProviderRuntimeModel | undefined { + const trimmedModelId = ctx.modelId.trim(); + const lower = trimmedModelId.toLowerCase(); + let templateIds: readonly string[]; + if (lower === OPENAI_GPT_54_MODEL_ID) { + templateIds = OPENAI_GPT_54_TEMPLATE_MODEL_IDS; + } else if (lower === OPENAI_GPT_54_PRO_MODEL_ID) { + templateIds = OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS; + } else { + return undefined; + } + + return ( + cloneFirstTemplateModel({ + providerId: PROVIDER_ID, + modelId: trimmedModelId, + templateIds, + ctx, + patch: { + api: "openai-responses", + provider: PROVIDER_ID, + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, + maxTokens: OPENAI_GPT_54_MAX_TOKENS, + }, + }) ?? + normalizeModelCompat({ + id: trimmedModelId, + name: trimmedModelId, + api: "openai-responses", + provider: PROVIDER_ID, + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, + maxTokens: OPENAI_GPT_54_MAX_TOKENS, + } as ProviderRuntimeModel) + ); +} + +export function buildOpenAIProvider(): ProviderPlugin { + return { + id: PROVIDER_ID, + label: "OpenAI", + docsPath: "/providers/models", + envVars: ["OPENAI_API_KEY"], + auth: [], + resolveDynamicModel: (ctx) => resolveOpenAIGpt54ForwardCompatModel(ctx), + normalizeResolvedModel: (ctx) => { + if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) { + return undefined; + } + return normalizeOpenAITransport(ctx.model); + }, + capabilities: { + providerFamily: "openai", + }, + buildMissingAuthMessage: (ctx) => { + if (ctx.provider !== PROVIDER_ID || ctx.listProfileIds("openai-codex").length === 0) { + return undefined; + } + return 'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai-codex/gpt-5.4 (OAuth) or set OPENAI_API_KEY to use openai/gpt-5.4.'; + }, + suppressBuiltInModel: (ctx) => { + if ( + !SUPPRESSED_SPARK_PROVIDERS.has(normalizeProviderId(ctx.provider)) || + ctx.modelId.toLowerCase() !== OPENAI_DIRECT_SPARK_MODEL_ID + ) { + return undefined; + } + return { + suppress: true, + errorMessage: `Unknown model: ${ctx.provider}/${OPENAI_DIRECT_SPARK_MODEL_ID}. ${OPENAI_DIRECT_SPARK_MODEL_ID} is only supported via openai-codex OAuth. Use openai-codex/${OPENAI_DIRECT_SPARK_MODEL_ID}.`, + }; + }, + augmentModelCatalog: (ctx) => { + const openAiGpt54Template = findCatalogTemplate({ + entries: ctx.entries, + providerId: PROVIDER_ID, + templateIds: OPENAI_GPT_54_TEMPLATE_MODEL_IDS, + }); + const openAiGpt54ProTemplate = findCatalogTemplate({ + entries: ctx.entries, + providerId: PROVIDER_ID, + templateIds: OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS, + }); + return [ + openAiGpt54Template + ? { + ...openAiGpt54Template, + id: OPENAI_GPT_54_MODEL_ID, + name: OPENAI_GPT_54_MODEL_ID, + } + : undefined, + openAiGpt54ProTemplate + ? { + ...openAiGpt54ProTemplate, + id: OPENAI_GPT_54_PRO_MODEL_ID, + name: OPENAI_GPT_54_PRO_MODEL_ID, + } + : undefined, + ].filter((entry): entry is NonNullable => entry !== undefined); + }, + }; +} diff --git a/extensions/openai/openclaw.plugin.json b/extensions/openai/openclaw.plugin.json index 4bae96f3619..480e80a59ce 100644 --- a/extensions/openai/openclaw.plugin.json +++ b/extensions/openai/openclaw.plugin.json @@ -1,6 +1,6 @@ { "id": "openai", - "providers": ["openai"], + "providers": ["openai", "openai-codex"], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/openai/package.json b/extensions/openai/package.json index c5e73ed8120..1e4599dc157 100644 --- a/extensions/openai/package.json +++ b/extensions/openai/package.json @@ -2,7 +2,7 @@ "name": "@openclaw/openai-provider", "version": "2026.3.14", "private": true, - "description": "OpenClaw OpenAI provider plugin", + "description": "OpenClaw OpenAI provider plugins", "type": "module", "openclaw": { "extensions": [ diff --git a/extensions/openai/shared.ts b/extensions/openai/shared.ts new file mode 100644 index 00000000000..c8654be2f9b --- /dev/null +++ b/extensions/openai/shared.ts @@ -0,0 +1,57 @@ +import { normalizeModelCompat } from "../../src/agents/model-compat.js"; +import type { + ProviderResolveDynamicModelContext, + ProviderRuntimeModel, +} from "../../src/plugins/types.js"; + +export const OPENAI_API_BASE_URL = "https://api.openai.com/v1"; + +export function isOpenAIApiBaseUrl(baseUrl?: string): boolean { + const trimmed = baseUrl?.trim(); + if (!trimmed) { + return false; + } + return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/i.test(trimmed); +} + +export function cloneFirstTemplateModel(params: { + providerId: string; + modelId: string; + templateIds: readonly string[]; + ctx: ProviderResolveDynamicModelContext; + patch?: Partial; +}): ProviderRuntimeModel | undefined { + const trimmedModelId = params.modelId.trim(); + for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { + const template = params.ctx.modelRegistry.find( + params.providerId, + templateId, + ) as ProviderRuntimeModel | null; + if (!template) { + continue; + } + return normalizeModelCompat({ + ...template, + id: trimmedModelId, + name: trimmedModelId, + ...params.patch, + } as ProviderRuntimeModel); + } + return undefined; +} + +export function findCatalogTemplate(params: { + entries: ReadonlyArray<{ provider: string; id: string }>; + providerId: string; + templateIds: readonly string[]; +}) { + return params.templateIds + .map((templateId) => + params.entries.find( + (entry) => + entry.provider.toLowerCase() === params.providerId.toLowerCase() && + entry.id.toLowerCase() === templateId.toLowerCase(), + ), + ) + .find((entry) => entry !== undefined); +} diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index fb3abd1571e..7064b2fcd01 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -6,6 +6,7 @@ import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types import { coerceSecretRef } from "../config/types.secrets.js"; import { getShellEnvAppliedKeys } from "../infra/shell-env.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { buildProviderMissingAuthMessageWithPlugin } from "../plugins/provider-runtime.js"; import { normalizeOptionalSecretInput, normalizeSecretInput, @@ -358,13 +359,19 @@ export async function resolveApiKeyForProvider(params: { return resolveAwsSdkAuthInfo(); } - if (provider === "openai") { - const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0; - if (hasCodex) { - throw new Error( - 'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai-codex/gpt-5.4 (OAuth) or set OPENAI_API_KEY to use openai/gpt-5.4.', - ); - } + const pluginMissingAuthMessage = buildProviderMissingAuthMessageWithPlugin({ + provider, + config: cfg, + context: { + config: cfg, + agentDir: params.agentDir, + env: process.env, + provider, + listProfileIds: (providerId) => listProfilesForProvider(store, providerId), + }, + }); + if (pluginMissingAuthMessage) { + throw new Error(pluginMissingAuthMessage); } const authStorePath = resolveAuthStorePathForDisplay(params.agentDir); diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index 6f66e85c49c..4274333a518 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -1,5 +1,6 @@ import { type OpenClawConfig, loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { augmentModelCatalogWithProviderPlugins } from "../plugins/provider-runtime.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { shouldSuppressBuiltInModel } from "./model-suppression.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; @@ -33,70 +34,8 @@ let hasLoggedModelCatalogError = false; const defaultImportPiSdk = () => import("./pi-model-discovery-runtime.js"); let importPiSdk = defaultImportPiSdk; -const CODEX_PROVIDER = "openai-codex"; -const OPENAI_PROVIDER = "openai"; -const OPENAI_GPT54_MODEL_ID = "gpt-5.4"; -const OPENAI_GPT54_PRO_MODEL_ID = "gpt-5.4-pro"; -const OPENAI_CODEX_GPT53_MODEL_ID = "gpt-5.3-codex"; -const OPENAI_CODEX_GPT53_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; -const OPENAI_CODEX_GPT54_MODEL_ID = "gpt-5.4"; const NON_PI_NATIVE_MODEL_PROVIDERS = new Set(["kilocode"]); -type SyntheticCatalogFallback = { - provider: string; - id: string; - templateIds: readonly string[]; -}; - -const SYNTHETIC_CATALOG_FALLBACKS: readonly SyntheticCatalogFallback[] = [ - { - provider: OPENAI_PROVIDER, - id: OPENAI_GPT54_MODEL_ID, - templateIds: ["gpt-5.2"], - }, - { - provider: OPENAI_PROVIDER, - id: OPENAI_GPT54_PRO_MODEL_ID, - templateIds: ["gpt-5.2-pro", "gpt-5.2"], - }, - { - provider: CODEX_PROVIDER, - id: OPENAI_CODEX_GPT54_MODEL_ID, - templateIds: ["gpt-5.3-codex", "gpt-5.2-codex"], - }, - { - provider: CODEX_PROVIDER, - id: OPENAI_CODEX_GPT53_SPARK_MODEL_ID, - templateIds: [OPENAI_CODEX_GPT53_MODEL_ID], - }, -] as const; - -function applySyntheticCatalogFallbacks(models: ModelCatalogEntry[]): void { - const findCatalogEntry = (provider: string, id: string) => - models.find( - (entry) => - entry.provider.toLowerCase() === provider.toLowerCase() && - entry.id.toLowerCase() === id.toLowerCase(), - ); - - for (const fallback of SYNTHETIC_CATALOG_FALLBACKS) { - if (findCatalogEntry(fallback.provider, fallback.id)) { - continue; - } - const template = fallback.templateIds - .map((templateId) => findCatalogEntry(fallback.provider, templateId)) - .find((entry) => entry !== undefined); - if (!template) { - continue; - } - models.push({ - ...template, - id: fallback.id, - name: fallback.id, - }); - } -} - function normalizeConfiguredModelInput(input: unknown): ModelInputType[] | undefined { if (!Array.isArray(input)) { return undefined; @@ -256,7 +195,31 @@ export async function loadModelCatalog(params?: { models.push({ id, name, provider, contextWindow, reasoning, input }); } mergeConfiguredOptInProviderModels({ config: cfg, models }); - applySyntheticCatalogFallbacks(models); + const supplemental = await augmentModelCatalogWithProviderPlugins({ + config: cfg, + env: process.env, + context: { + config: cfg, + agentDir, + env: process.env, + entries: [...models], + }, + }); + if (supplemental.length > 0) { + const seen = new Set( + models.map( + (entry) => `${entry.provider.toLowerCase().trim()}::${entry.id.toLowerCase().trim()}`, + ), + ); + for (const entry of supplemental) { + const key = `${entry.provider.toLowerCase().trim()}::${entry.id.toLowerCase().trim()}`; + if (seen.has(key)) { + continue; + } + models.push(entry); + seen.add(key); + } + } if (models.length === 0) { // If we found nothing, don't cache this result so we can try again. diff --git a/src/agents/model-forward-compat.ts b/src/agents/model-forward-compat.ts index 709afc2ee4d..5319d30423e 100644 --- a/src/agents/model-forward-compat.ts +++ b/src/agents/model-forward-compat.ts @@ -4,83 +4,18 @@ import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js"; import { normalizeModelCompat } from "./model-compat.js"; import { normalizeProviderId } from "./model-selection.js"; -const OPENAI_GPT_54_MODEL_ID = "gpt-5.4"; -const OPENAI_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro"; -const OPENAI_GPT_54_CONTEXT_TOKENS = 1_050_000; -const OPENAI_GPT_54_MAX_TOKENS = 128_000; -const OPENAI_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.2"] as const; -const OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS = ["gpt-5.2-pro", "gpt-5.2"] as const; - -const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6"; -const ANTHROPIC_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6"; -const ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] as const; -const ANTHROPIC_SONNET_46_MODEL_ID = "claude-sonnet-4-6"; -const ANTHROPIC_SONNET_46_DOT_MODEL_ID = "claude-sonnet-4.6"; -const ANTHROPIC_SONNET_TEMPLATE_MODEL_IDS = ["claude-sonnet-4-5", "claude-sonnet-4.5"] as const; - const ZAI_GLM5_MODEL_ID = "glm-5"; const ZAI_GLM5_TEMPLATE_MODEL_IDS = ["glm-4.7"] as const; -// gemini-3.1-pro-preview / gemini-3.1-flash-preview are not yet in pi-ai's built-in -// google-gemini-cli catalog. Clone the gemini-3-pro/flash-preview template so users -// don't get "Unknown model" errors when Google releases a new minor version. +// gemini-3.1-pro-preview / gemini-3.1-flash-preview are not present in some pi-ai +// Google catalogs yet. Clone the nearest gemini-3 template so users don't get +// "Unknown model" errors when Google ships new minor-version models before pi-ai +// updates its built-in registry. const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro"; const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash"; const GEMINI_3_1_PRO_TEMPLATE_IDS = ["gemini-3-pro-preview"] as const; const GEMINI_3_1_FLASH_TEMPLATE_IDS = ["gemini-3-flash-preview"] as const; -function resolveOpenAIGpt54ForwardCompatModel( - provider: string, - modelId: string, - modelRegistry: ModelRegistry, -): Model | undefined { - const normalizedProvider = normalizeProviderId(provider); - if (normalizedProvider !== "openai") { - return undefined; - } - - const trimmedModelId = modelId.trim(); - const lower = trimmedModelId.toLowerCase(); - let templateIds: readonly string[]; - if (lower === OPENAI_GPT_54_MODEL_ID) { - templateIds = OPENAI_GPT_54_TEMPLATE_MODEL_IDS; - } else if (lower === OPENAI_GPT_54_PRO_MODEL_ID) { - templateIds = OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS; - } else { - return undefined; - } - - return ( - cloneFirstTemplateModel({ - normalizedProvider, - trimmedModelId, - templateIds: [...templateIds], - modelRegistry, - patch: { - api: "openai-responses", - provider: normalizedProvider, - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, - maxTokens: OPENAI_GPT_54_MAX_TOKENS, - }, - }) ?? - normalizeModelCompat({ - id: trimmedModelId, - name: trimmedModelId, - api: "openai-responses", - provider: normalizedProvider, - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, - maxTokens: OPENAI_GPT_54_MAX_TOKENS, - } as Model) - ); -} - function cloneFirstTemplateModel(params: { normalizedProvider: string; trimmedModelId: string; @@ -104,88 +39,6 @@ function cloneFirstTemplateModel(params: { return undefined; } -function resolveAnthropic46ForwardCompatModel(params: { - provider: string; - modelId: string; - modelRegistry: ModelRegistry; - dashModelId: string; - dotModelId: string; - dashTemplateId: string; - dotTemplateId: string; - fallbackTemplateIds: readonly string[]; -}): Model | undefined { - const { provider, modelId, modelRegistry, dashModelId, dotModelId } = params; - const normalizedProvider = normalizeProviderId(provider); - if (normalizedProvider !== "anthropic") { - return undefined; - } - - const trimmedModelId = modelId.trim(); - const lower = trimmedModelId.toLowerCase(); - const is46Model = - lower === dashModelId || - lower === dotModelId || - lower.startsWith(`${dashModelId}-`) || - lower.startsWith(`${dotModelId}-`); - if (!is46Model) { - return undefined; - } - - const templateIds: string[] = []; - if (lower.startsWith(dashModelId)) { - templateIds.push(lower.replace(dashModelId, params.dashTemplateId)); - } - if (lower.startsWith(dotModelId)) { - templateIds.push(lower.replace(dotModelId, params.dotTemplateId)); - } - templateIds.push(...params.fallbackTemplateIds); - - return cloneFirstTemplateModel({ - normalizedProvider, - trimmedModelId, - templateIds, - modelRegistry, - }); -} - -function resolveAnthropicOpus46ForwardCompatModel( - provider: string, - modelId: string, - modelRegistry: ModelRegistry, -): Model | undefined { - return resolveAnthropic46ForwardCompatModel({ - provider, - modelId, - modelRegistry, - dashModelId: ANTHROPIC_OPUS_46_MODEL_ID, - dotModelId: ANTHROPIC_OPUS_46_DOT_MODEL_ID, - dashTemplateId: "claude-opus-4-5", - dotTemplateId: "claude-opus-4.5", - fallbackTemplateIds: ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS, - }); -} - -function resolveAnthropicSonnet46ForwardCompatModel( - provider: string, - modelId: string, - modelRegistry: ModelRegistry, -): Model | undefined { - return resolveAnthropic46ForwardCompatModel({ - provider, - modelId, - modelRegistry, - dashModelId: ANTHROPIC_SONNET_46_MODEL_ID, - dotModelId: ANTHROPIC_SONNET_46_DOT_MODEL_ID, - dashTemplateId: "claude-sonnet-4-5", - dotTemplateId: "claude-sonnet-4.5", - fallbackTemplateIds: ANTHROPIC_SONNET_TEMPLATE_MODEL_IDS, - }); -} - -// gemini-3.1-pro-preview / gemini-3.1-flash-preview are not present in some pi-ai -// Google catalogs yet. Clone the nearest gemini-3 template so users don't get -// "Unknown model" errors when Google ships new minor-version models before pi-ai -// updates its built-in registry. function resolveGoogle31ForwardCompatModel( provider: string, modelId: string, @@ -264,9 +117,6 @@ export function resolveForwardCompatModel( modelRegistry: ModelRegistry, ): Model | undefined { return ( - resolveOpenAIGpt54ForwardCompatModel(provider, modelId, modelRegistry) ?? - resolveAnthropicOpus46ForwardCompatModel(provider, modelId, modelRegistry) ?? - resolveAnthropicSonnet46ForwardCompatModel(provider, modelId, modelRegistry) ?? resolveZaiGlm5ForwardCompatModel(provider, modelId, modelRegistry) ?? resolveGoogle31ForwardCompatModel(provider, modelId, modelRegistry) ); diff --git a/src/agents/model-suppression.ts b/src/agents/model-suppression.ts index 378096ea732..ac1dcccdb74 100644 --- a/src/agents/model-suppression.ts +++ b/src/agents/model-suppression.ts @@ -1,27 +1,32 @@ +import { resolveProviderBuiltInModelSuppression } from "../plugins/provider-runtime.js"; import { normalizeProviderId } from "./model-selection.js"; -const OPENAI_DIRECT_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; -const SUPPRESSED_SPARK_PROVIDERS = new Set(["openai", "azure-openai-responses"]); +function resolveBuiltInModelSuppression(params: { provider?: string | null; id?: string | null }) { + const provider = normalizeProviderId(params.provider?.trim().toLowerCase() ?? ""); + const modelId = params.id?.trim().toLowerCase() ?? ""; + if (!provider || !modelId) { + return undefined; + } + return resolveProviderBuiltInModelSuppression({ + env: process.env, + context: { + env: process.env, + provider, + modelId, + }, + }); +} export function shouldSuppressBuiltInModel(params: { provider?: string | null; id?: string | null; }) { - const provider = normalizeProviderId(params.provider?.trim().toLowerCase() ?? ""); - const id = params.id?.trim().toLowerCase() ?? ""; - - // pi-ai still ships non-Codex Spark rows, but OpenClaw treats Spark as - // Codex-only until upstream availability is proven on direct API paths. - return SUPPRESSED_SPARK_PROVIDERS.has(provider) && id === OPENAI_DIRECT_SPARK_MODEL_ID; + return resolveBuiltInModelSuppression(params)?.suppress ?? false; } export function buildSuppressedBuiltInModelError(params: { provider?: string | null; id?: string | null; }): string | undefined { - if (!shouldSuppressBuiltInModel(params)) { - return undefined; - } - const provider = normalizeProviderId(params.provider?.trim().toLowerCase() ?? "") || "openai"; - return `Unknown model: ${provider}/${OPENAI_DIRECT_SPARK_MODEL_ID}. ${OPENAI_DIRECT_SPARK_MODEL_ID} is only supported via openai-codex OAuth. Use openai-codex/${OPENAI_DIRECT_SPARK_MODEL_ID}.`; + return resolveBuiltInModelSuppression(params)?.errorMessage; } diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 7263155c1ad..ed6356a361f 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -34,7 +34,7 @@ type InlineProviderConfig = { headers?: unknown; }; -const PLUGIN_FIRST_DYNAMIC_PROVIDERS = new Set(["anthropic", "google-gemini-cli", "openai", "zai"]); +const PLUGIN_FIRST_DYNAMIC_PROVIDERS = new Set(["google-gemini-cli", "zai"]); function sanitizeModelHeaders( headers: unknown, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index d8b94a53545..4f403343b34 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -4,6 +4,10 @@ export type { ProviderDiscoveryContext, ProviderCatalogContext, ProviderCatalogResult, + ProviderAugmentModelCatalogContext, + ProviderBuiltInModelSuppressionContext, + ProviderBuiltInModelSuppressionResult, + ProviderBuildMissingAuthMessageContext, ProviderCacheTtlEligibilityContext, ProviderFetchUsageSnapshotContext, ProviderPreparedRuntimeAuth, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 5c8c514d191..089876dc7bc 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -109,6 +109,10 @@ export type { PluginLogger, ProviderAuthContext, ProviderAuthResult, + ProviderAugmentModelCatalogContext, + ProviderBuiltInModelSuppressionContext, + ProviderBuiltInModelSuppressionResult, + ProviderBuildMissingAuthMessageContext, ProviderCacheTtlEligibilityContext, ProviderFetchUsageSnapshotContext, ProviderPreparedRuntimeAuth, diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index 2d287a71e34..37db8a6efae 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -77,6 +77,22 @@ describe("normalizePluginsConfig", () => { }); expect(result.entries["voice-call"]?.hooks).toBeUndefined(); }); + + it("normalizes legacy plugin ids to their merged bundled plugin id", () => { + const result = normalizePluginsConfig({ + allow: ["openai-codex"], + deny: ["openai-codex"], + entries: { + "openai-codex": { + enabled: true, + }, + }, + }); + + expect(result.allow).toEqual(["openai"]); + expect(result.deny).toEqual(["openai"]); + expect(result.entries.openai?.enabled).toBe(true); + }); }); describe("resolveEffectiveEnableState", () => { diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 26a65b61cd9..a5860b606e3 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -40,7 +40,6 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ "nvidia", "ollama", "openai", - "openai-codex", "opencode", "opencode-go", "openrouter", @@ -59,11 +58,22 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ "zai", ]); +const PLUGIN_ID_ALIASES: Readonly> = { + "openai-codex": "openai", +}; + +function normalizePluginId(id: string): string { + const trimmed = id.trim(); + return PLUGIN_ID_ALIASES[trimmed] ?? trimmed; +} + const normalizeList = (value: unknown): string[] => { if (!Array.isArray(value)) { return []; } - return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); + return value + .map((entry) => (typeof entry === "string" ? normalizePluginId(entry) : "")) + .filter(Boolean); }; const normalizeSlotValue = (value: unknown): string | null | undefined => { @@ -86,11 +96,12 @@ const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entr } const normalized: NormalizedPluginsConfig["entries"] = {}; for (const [key, value] of Object.entries(entries)) { - if (!key.trim()) { + const normalizedKey = normalizePluginId(key); + if (!normalizedKey) { continue; } if (!value || typeof value !== "object" || Array.isArray(value)) { - normalized[key] = {}; + normalized[normalizedKey] = {}; continue; } const entry = value as Record; @@ -108,10 +119,12 @@ const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entr allowPromptInjection: hooks.allowPromptInjection, } : undefined; - normalized[key] = { - enabled: typeof entry.enabled === "boolean" ? entry.enabled : undefined, - hooks: normalizedHooks, - config: "config" in entry ? entry.config : undefined, + normalized[normalizedKey] = { + ...normalized[normalizedKey], + enabled: + typeof entry.enabled === "boolean" ? entry.enabled : normalized[normalizedKey]?.enabled, + hooks: normalizedHooks ?? normalized[normalizedKey]?.hooks, + config: "config" in entry ? entry.config : normalized[normalizedKey]?.config, }; } return normalized; diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 1ca9ef446b6..af5066b5453 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -8,8 +8,11 @@ vi.mock("./providers.js", () => ({ })); import { + augmentModelCatalogWithProviderPlugins, + buildProviderMissingAuthMessageWithPlugin, prepareProviderExtraParams, resolveProviderCacheTtlEligibility, + resolveProviderBuiltInModelSuppression, resolveProviderUsageSnapshotWithPlugin, resolveProviderCapabilitiesWithPlugin, resolveProviderUsageAuthWithPlugin, @@ -57,6 +60,7 @@ describe("provider-runtime", () => { expect.objectContaining({ provider: "Open Router", bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, }), ); }); @@ -77,31 +81,59 @@ describe("provider-runtime", () => { displayName: "Demo", windows: [{ label: "Day", usedPercent: 25 }], })); - resolvePluginProvidersMock.mockReturnValue([ - { - id: "demo", - label: "Demo", - auth: [], - resolveDynamicModel: () => MODEL, - prepareDynamicModel, - capabilities: { - providerFamily: "openai", + resolvePluginProvidersMock.mockImplementation((params?: { onlyPluginIds?: string[] }) => { + if (params?.onlyPluginIds?.includes("openai")) { + return [ + { + id: "openai", + label: "OpenAI", + auth: [], + buildMissingAuthMessage: () => + 'No API key found for provider "openai". Use openai-codex/gpt-5.4.', + suppressBuiltInModel: ({ provider, modelId }) => + provider === "azure-openai-responses" && modelId === "gpt-5.3-codex-spark" + ? { suppress: true, errorMessage: "openai-codex/gpt-5.3-codex-spark" } + : undefined, + augmentModelCatalog: () => [ + { provider: "openai", id: "gpt-5.4", name: "gpt-5.4" }, + { provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" }, + { provider: "openai-codex", id: "gpt-5.4", name: "gpt-5.4" }, + { + provider: "openai-codex", + id: "gpt-5.3-codex-spark", + name: "gpt-5.3-codex-spark", + }, + ], + }, + ]; + } + + return [ + { + id: "demo", + label: "Demo", + auth: [], + resolveDynamicModel: () => MODEL, + prepareDynamicModel, + capabilities: { + providerFamily: "openai", + }, + prepareExtraParams: ({ extraParams }) => ({ + ...extraParams, + transport: "auto", + }), + wrapStreamFn: ({ streamFn }) => streamFn, + normalizeResolvedModel: ({ model }) => ({ + ...model, + api: "openai-codex-responses", + }), + prepareRuntimeAuth, + resolveUsageAuth, + fetchUsageSnapshot, + isCacheTtlEligible: ({ modelId }) => modelId.startsWith("anthropic/"), }, - prepareExtraParams: ({ extraParams }) => ({ - ...extraParams, - transport: "auto", - }), - wrapStreamFn: ({ streamFn }) => streamFn, - normalizeResolvedModel: ({ model }) => ({ - ...model, - api: "openai-codex-responses", - }), - prepareRuntimeAuth, - resolveUsageAuth, - fetchUsageSnapshot, - isCacheTtlEligible: ({ modelId }) => modelId.startsWith("anthropic/"), - }, - ]); + ]; + }); expect( runProviderDynamicModel({ @@ -234,6 +266,60 @@ describe("provider-runtime", () => { }), ).toBe(true); + expect( + buildProviderMissingAuthMessageWithPlugin({ + provider: "openai", + env: process.env, + context: { + env: process.env, + provider: "openai", + listProfileIds: (providerId) => (providerId === "openai-codex" ? ["p1"] : []), + }, + }), + ).toContain("openai-codex/gpt-5.4"); + + expect( + resolveProviderBuiltInModelSuppression({ + env: process.env, + context: { + env: process.env, + provider: "azure-openai-responses", + modelId: "gpt-5.3-codex-spark", + }, + }), + ).toMatchObject({ + suppress: true, + errorMessage: expect.stringContaining("openai-codex/gpt-5.3-codex-spark"), + }); + + await expect( + augmentModelCatalogWithProviderPlugins({ + env: process.env, + context: { + env: process.env, + entries: [ + { provider: "openai", id: "gpt-5.2", name: "GPT-5.2" }, + { provider: "openai", id: "gpt-5.2-pro", name: "GPT-5.2 Pro" }, + { provider: "openai-codex", id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + ], + }, + }), + ).resolves.toEqual([ + { provider: "openai", id: "gpt-5.4", name: "gpt-5.4" }, + { provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" }, + { provider: "openai-codex", id: "gpt-5.4", name: "gpt-5.4" }, + { + provider: "openai-codex", + id: "gpt-5.3-codex-spark", + name: "gpt-5.3-codex-spark", + }, + ]); + + expect(resolvePluginProvidersMock).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["openai"], + }), + ); expect(prepareDynamicModel).toHaveBeenCalledTimes(1); expect(prepareRuntimeAuth).toHaveBeenCalledTimes(1); expect(resolveUsageAuth).toHaveBeenCalledTimes(1); diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 7397a52abae..e7ee62d8ebf 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -2,6 +2,9 @@ import { normalizeProviderId } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolvePluginProviders } from "./providers.js"; import type { + ProviderAugmentModelCatalogContext, + ProviderBuildMissingAuthMessageContext, + ProviderBuiltInModelSuppressionContext, ProviderCacheTtlEligibilityContext, ProviderFetchUsageSnapshotContext, ProviderPrepareExtraParamsContext, @@ -25,16 +28,41 @@ function matchesProviderId(provider: ProviderPlugin, providerId: string): boolea return (provider.aliases ?? []).some((alias) => normalizeProviderId(alias) === normalized); } +function resolveProviderPluginsForHooks(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + onlyPluginIds?: string[]; +}): ProviderPlugin[] { + return resolvePluginProviders({ + ...params, + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + }); +} + +const GLOBAL_PROVIDER_HOOK_PLUGIN_IDS = ["openai"] as const; + +function resolveGlobalProviderHookPlugins(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): ProviderPlugin[] { + return resolveProviderPluginsForHooks({ + ...params, + onlyPluginIds: [...GLOBAL_PROVIDER_HOOK_PLUGIN_IDS], + }); +} + export function resolveProviderRuntimePlugin(params: { provider: string; config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; }): ProviderPlugin | undefined { - return resolvePluginProviders({ - ...params, - bundledProviderAllowlistCompat: true, - }).find((plugin) => matchesProviderId(plugin, params.provider)); + return resolveProviderPluginsForHooks(params).find((plugin) => + matchesProviderId(plugin, params.provider), + ); } export function runProviderDynamicModel(params: { @@ -144,3 +172,48 @@ export function resolveProviderCacheTtlEligibility(params: { }) { return resolveProviderRuntimePlugin(params)?.isCacheTtlEligible?.(params.context); } + +export function buildProviderMissingAuthMessageWithPlugin(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderBuildMissingAuthMessageContext; +}) { + const plugin = resolveGlobalProviderHookPlugins(params).find((providerPlugin) => + matchesProviderId(providerPlugin, params.provider), + ); + return plugin?.buildMissingAuthMessage?.(params.context) ?? undefined; +} + +export function resolveProviderBuiltInModelSuppression(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderBuiltInModelSuppressionContext; +}) { + for (const plugin of resolveGlobalProviderHookPlugins(params)) { + const result = plugin.suppressBuiltInModel?.(params.context); + if (result?.suppress) { + return result; + } + } + return undefined; +} + +export async function augmentModelCatalogWithProviderPlugins(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderAugmentModelCatalogContext; +}) { + const supplemental = [] as ProviderAugmentModelCatalogContext["entries"]; + for (const plugin of resolveGlobalProviderHookPlugins(params)) { + const next = await plugin.augmentModelCatalog?.(params.context); + if (!next || next.length === 0) { + continue; + } + supplemental.push(...next); + } + return supplemental; +} diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index 7df6432b4c3..4e238c2193d 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -52,4 +52,22 @@ describe("resolvePluginProviders", () => { }), ); }); + + it("can enable bundled provider plugins under Vitest when no explicit plugin config exists", () => { + resolvePluginProviders({ + env: { VITEST: "1" } as NodeJS.ProcessEnv, + bundledProviderVitestCompat: true, + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + plugins: expect.objectContaining({ + enabled: true, + allow: expect.arrayContaining(["openai", "moonshot", "zai"]), + }), + }), + }), + ); + }); }); diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 7e18664067b..010766e5fa9 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -22,7 +22,6 @@ const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "nvidia", "ollama", "openai", - "openai-codex", "opencode", "opencode-go", "openrouter", @@ -39,6 +38,32 @@ const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "zai", ] as const; +function hasExplicitPluginConfig(config: PluginLoadOptions["config"]): boolean { + const plugins = config?.plugins; + if (!plugins) { + return false; + } + if (typeof plugins.enabled === "boolean") { + return true; + } + if (Array.isArray(plugins.allow) && plugins.allow.length > 0) { + return true; + } + if (Array.isArray(plugins.deny) && plugins.deny.length > 0) { + return true; + } + if (Array.isArray(plugins.load?.paths) && plugins.load.paths.length > 0) { + return true; + } + if (plugins.entries && Object.keys(plugins.entries).length > 0) { + return true; + } + if (plugins.slots && Object.keys(plugins.slots).length > 0) { + return true; + } + return false; +} + function withBundledProviderAllowlistCompat( config: PluginLoadOptions["config"], ): PluginLoadOptions["config"] { @@ -71,20 +96,52 @@ function withBundledProviderAllowlistCompat( }; } +function withBundledProviderVitestCompat(params: { + config: PluginLoadOptions["config"]; + env?: PluginLoadOptions["env"]; +}): PluginLoadOptions["config"] { + const env = params.env ?? process.env; + if (!env.VITEST || hasExplicitPluginConfig(params.config)) { + return params.config; + } + + return { + ...params.config, + plugins: { + ...params.config?.plugins, + enabled: true, + allow: [...BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS], + slots: { + ...params.config?.plugins?.slots, + memory: "none", + }, + }, + }; +} + export function resolvePluginProviders(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; /** Use an explicit env when plugin roots should resolve independently from process.env. */ env?: PluginLoadOptions["env"]; bundledProviderAllowlistCompat?: boolean; + bundledProviderVitestCompat?: boolean; + onlyPluginIds?: string[]; }): ProviderPlugin[] { - const config = params.bundledProviderAllowlistCompat + const maybeAllowlistCompat = params.bundledProviderAllowlistCompat ? withBundledProviderAllowlistCompat(params.config) : params.config; + const config = params.bundledProviderVitestCompat + ? withBundledProviderVitestCompat({ + config: maybeAllowlistCompat, + env: params.env, + }) + : maybeAllowlistCompat; const registry = loadOpenClawPlugins({ config, workspaceDir: params.workspaceDir, env: params.env, + onlyPluginIds: params.onlyPluginIds, logger: createPluginLoaderLogger(log), }); diff --git a/src/plugins/types.ts b/src/plugins/types.ts index d96a8c65d8d..9ad44fff40d 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -10,6 +10,7 @@ import type { AuthProfileCredential, OAuthCredential, } from "../agents/auth-profiles/types.js"; +import type { ModelCatalogEntry } from "../agents/model-catalog.js"; import type { ProviderCapabilities } from "../agents/provider-capabilities.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import type { ThinkLevel } from "../auto-reply/thinking.js"; @@ -390,6 +391,59 @@ export type ProviderCacheTtlEligibilityContext = { modelId: string; }; +/** + * Provider-owned missing-auth message override. + * + * Runs only after OpenClaw exhausts normal env/profile/config auth resolution + * for the requested provider. Return a custom message to replace the generic + * "No API key found" error. + */ +export type ProviderBuildMissingAuthMessageContext = { + config?: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + provider: string; + listProfileIds: (providerId: string) => string[]; +}; + +/** + * Built-in model suppression hook. + * + * Use this when a provider/plugin needs to hide stale upstream catalog rows or + * replace them with a vendor-specific hint. This hook is consulted by model + * resolution, model listing, and catalog loading. + */ +export type ProviderBuiltInModelSuppressionContext = { + config?: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + provider: string; + modelId: string; +}; + +export type ProviderBuiltInModelSuppressionResult = { + suppress: boolean; + errorMessage?: string; +}; + +/** + * Final catalog augmentation hook. + * + * Runs after OpenClaw loads the discovered model catalog and merges configured + * opt-in providers. Use this for forward-compat rows or vendor-owned synthetic + * entries that should appear in `models list` and model pickers even when the + * upstream registry has not caught up yet. + */ +export type ProviderAugmentModelCatalogContext = { + config?: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + entries: ModelCatalogEntry[]; +}; + /** * @deprecated Use ProviderCatalogOrder. */ @@ -560,6 +614,40 @@ export type ProviderPlugin = { * only a subset of upstream models. */ isCacheTtlEligible?: (ctx: ProviderCacheTtlEligibilityContext) => boolean | undefined; + /** + * Provider-owned missing-auth message override. + * + * Return a custom message when the provider wants a more specific recovery + * hint than OpenClaw's generic auth-store guidance. + */ + buildMissingAuthMessage?: ( + ctx: ProviderBuildMissingAuthMessageContext, + ) => string | null | undefined; + /** + * Provider-owned built-in model suppression. + * + * Return `{ suppress: true }` to hide a stale upstream row. Include + * `errorMessage` when OpenClaw should surface a provider-specific hint for + * direct model resolution failures. + */ + suppressBuiltInModel?: ( + ctx: ProviderBuiltInModelSuppressionContext, + ) => ProviderBuiltInModelSuppressionResult | null | undefined; + /** + * Provider-owned final catalog augmentation. + * + * Return extra rows to append to the final catalog after discovery/config + * merging. OpenClaw deduplicates by `provider/id`, so plugins only need to + * describe the desired supplemental rows. + */ + augmentModelCatalog?: ( + ctx: ProviderAugmentModelCatalogContext, + ) => + | Array + | ReadonlyArray + | Promise | ReadonlyArray | null | undefined> + | null + | undefined; wizard?: ProviderPluginWizard; formatApiKey?: (cred: AuthProfileCredential) => string; refreshOAuth?: (cred: OAuthCredential) => Promise; From 74a57ace10bce2f3e80639127101a683c60e456b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 18:20:29 -0700 Subject: [PATCH 089/943] refactor(plugins): lazy load provider runtime shims --- src/agents/model-auth.ts | 10 +++++++++- src/agents/model-catalog.ts | 18 ++++++++++++++++-- src/agents/model-suppression.runtime.ts | 1 + src/plugins/provider-runtime.runtime.ts | 4 ++++ src/plugins/provider-runtime.test.ts | 5 +++-- 5 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 src/agents/model-suppression.runtime.ts create mode 100644 src/plugins/provider-runtime.runtime.ts diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 7064b2fcd01..0616bc41194 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -6,7 +6,6 @@ import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types import { coerceSecretRef } from "../config/types.secrets.js"; import { getShellEnvAppliedKeys } from "../infra/shell-env.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { buildProviderMissingAuthMessageWithPlugin } from "../plugins/provider-runtime.js"; import { normalizeOptionalSecretInput, normalizeSecretInput, @@ -36,6 +35,14 @@ const AWS_BEARER_ENV = "AWS_BEARER_TOKEN_BEDROCK"; const AWS_ACCESS_KEY_ENV = "AWS_ACCESS_KEY_ID"; const AWS_SECRET_KEY_ENV = "AWS_SECRET_ACCESS_KEY"; const AWS_PROFILE_ENV = "AWS_PROFILE"; +let providerRuntimePromise: + | Promise + | undefined; + +function loadProviderRuntime() { + providerRuntimePromise ??= import("../plugins/provider-runtime.runtime.js"); + return providerRuntimePromise; +} function resolveProviderConfig( cfg: OpenClawConfig | undefined, @@ -359,6 +366,7 @@ export async function resolveApiKeyForProvider(params: { return resolveAwsSdkAuthInfo(); } + const { buildProviderMissingAuthMessageWithPlugin } = await loadProviderRuntime(); const pluginMissingAuthMessage = buildProviderMissingAuthMessageWithPlugin({ provider, config: cfg, diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index 4274333a518..983150f8d36 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -1,8 +1,6 @@ import { type OpenClawConfig, loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { augmentModelCatalogWithProviderPlugins } from "../plugins/provider-runtime.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; -import { shouldSuppressBuiltInModel } from "./model-suppression.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; const log = createSubsystemLogger("model-catalog"); @@ -33,9 +31,23 @@ let modelCatalogPromise: Promise | null = null; let hasLoggedModelCatalogError = false; const defaultImportPiSdk = () => import("./pi-model-discovery-runtime.js"); let importPiSdk = defaultImportPiSdk; +let providerRuntimePromise: + | Promise + | undefined; +let modelSuppressionPromise: Promise | undefined; const NON_PI_NATIVE_MODEL_PROVIDERS = new Set(["kilocode"]); +function loadProviderRuntime() { + providerRuntimePromise ??= import("../plugins/provider-runtime.runtime.js"); + return providerRuntimePromise; +} + +function loadModelSuppression() { + modelSuppressionPromise ??= import("./model-suppression.runtime.js"); + return modelSuppressionPromise; +} + function normalizeConfiguredModelInput(input: unknown): ModelInputType[] | undefined { if (!Array.isArray(input)) { return undefined; @@ -160,6 +172,8 @@ export async function loadModelCatalog(params?: { // will keep failing until restart). const piSdk = await importPiSdk(); const agentDir = resolveOpenClawAgentDir(); + const [{ shouldSuppressBuiltInModel }, { augmentModelCatalogWithProviderPlugins }] = + await Promise.all([loadModelSuppression(), loadProviderRuntime()]); const { join } = await import("node:path"); const authStorage = piSdk.discoverAuthStorage(agentDir); const registry = new (piSdk.ModelRegistry as unknown as { diff --git a/src/agents/model-suppression.runtime.ts b/src/agents/model-suppression.runtime.ts new file mode 100644 index 00000000000..472a662b810 --- /dev/null +++ b/src/agents/model-suppression.runtime.ts @@ -0,0 +1 @@ +export { shouldSuppressBuiltInModel } from "./model-suppression.js"; diff --git a/src/plugins/provider-runtime.runtime.ts b/src/plugins/provider-runtime.runtime.ts new file mode 100644 index 00000000000..34a46e1bdac --- /dev/null +++ b/src/plugins/provider-runtime.runtime.ts @@ -0,0 +1,4 @@ +export { + augmentModelCatalogWithProviderPlugins, + buildProviderMissingAuthMessageWithPlugin, +} from "./provider-runtime.js"; diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index af5066b5453..24bd47a915f 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -81,8 +81,9 @@ describe("provider-runtime", () => { displayName: "Demo", windows: [{ label: "Day", usedPercent: 25 }], })); - resolvePluginProvidersMock.mockImplementation((params?: { onlyPluginIds?: string[] }) => { - if (params?.onlyPluginIds?.includes("openai")) { + resolvePluginProvidersMock.mockImplementation((params: unknown) => { + const scopedParams = params as { onlyPluginIds?: string[] } | undefined; + if (scopedParams?.onlyPluginIds?.includes("openai")) { return [ { id: "openai", From 9c89a74f84c5c5b1811cb4a1c38d3bd1f4d330e5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 18:20:33 -0700 Subject: [PATCH 090/943] perf(cli): trim help startup imports --- scripts/check-cli-startup-memory.mjs | 63 ++++++--- src/cli/banner-config-lite.ts | 24 ++++ src/cli/banner.test.ts | 22 ++- src/cli/banner.ts | 9 +- src/cli/program/command-registry.ts | 41 ++---- src/cli/program/core-command-descriptors.ts | 104 ++++++++++++++ src/cli/program/help.ts | 4 +- src/cli/program/register.subclis.ts | 20 +-- src/cli/program/root-help.ts | 4 +- src/cli/program/subcli-descriptors.ts | 144 ++++++++++++++++++++ 10 files changed, 350 insertions(+), 85 deletions(-) create mode 100644 src/cli/banner-config-lite.ts create mode 100644 src/cli/program/core-command-descriptors.ts create mode 100644 src/cli/program/subcli-descriptors.ts diff --git a/scripts/check-cli-startup-memory.mjs b/scripts/check-cli-startup-memory.mjs index dbf666e1bfb..1b17e28ceea 100644 --- a/scripts/check-cli-startup-memory.mjs +++ b/scripts/check-cli-startup-memory.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -15,6 +15,21 @@ if (!isLinux && !isMac) { const repoRoot = process.cwd(); const tmpHome = mkdtempSync(path.join(os.tmpdir(), "openclaw-startup-memory-")); +const tmpDir = process.env.TMPDIR || process.env.TEMP || process.env.TMP || os.tmpdir(); +const rssHookPath = path.join(tmpHome, "measure-rss.mjs"); +const MAX_RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__="; + +writeFileSync( + rssHookPath, + [ + "process.on('exit', () => {", + " const usage = typeof process.resourceUsage === 'function' ? process.resourceUsage() : null;", + ` if (usage && typeof usage.maxRSS === 'number') console.error('${MAX_RSS_MARKER}' + String(usage.maxRSS));`, + "});", + "", + ].join("\n"), + "utf8", +); const DEFAULT_LIMITS_MB = { help: 500, @@ -26,13 +41,13 @@ const cases = [ { id: "help", label: "--help", - args: ["node", "openclaw.mjs", "--help"], + args: ["openclaw.mjs", "--help"], limitMb: Number(process.env.OPENCLAW_STARTUP_MEMORY_HELP_MB ?? DEFAULT_LIMITS_MB.help), }, { id: "statusJson", label: "status --json", - args: ["node", "openclaw.mjs", "status", "--json"], + args: ["openclaw.mjs", "status", "--json"], limitMb: Number( process.env.OPENCLAW_STARTUP_MEMORY_STATUS_JSON_MB ?? DEFAULT_LIMITS_MB.statusJson, ), @@ -40,7 +55,7 @@ const cases = [ { id: "gatewayStatus", label: "gateway status", - args: ["node", "openclaw.mjs", "gateway", "status"], + args: ["openclaw.mjs", "gateway", "status"], limitMb: Number( process.env.OPENCLAW_STARTUP_MEMORY_GATEWAY_STATUS_MB ?? DEFAULT_LIMITS_MB.gatewayStatus, ), @@ -48,30 +63,44 @@ const cases = [ ]; function parseMaxRssMb(stderr) { - if (isLinux) { - const match = stderr.match(/^\s*Maximum resident set size \(kbytes\):\s*(\d+)\s*$/im); - if (!match) { - return null; - } - return Number(match[1]) / 1024; - } - const match = stderr.match(/^\s*(\d+)\s+maximum resident set size\s*$/im); + const match = stderr.match(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "m")); if (!match) { return null; } - return Number(match[1]) / (1024 * 1024); + return Number(match[1]) / 1024; } -function runCase(testCase) { +function buildBenchEnv() { const env = { - ...process.env, HOME: tmpHome, + USERPROFILE: tmpHome, XDG_CONFIG_HOME: path.join(tmpHome, ".config"), XDG_DATA_HOME: path.join(tmpHome, ".local", "share"), XDG_CACHE_HOME: path.join(tmpHome, ".cache"), + PATH: process.env.PATH ?? "", + TMPDIR: tmpDir, + TEMP: tmpDir, + TMP: tmpDir, + LANG: process.env.LANG ?? "C.UTF-8", + TERM: process.env.TERM ?? "dumb", }; - const timeArgs = isLinux ? ["-v", ...testCase.args] : ["-l", ...testCase.args]; - const result = spawnSync("/usr/bin/time", timeArgs, { + + if (process.env.LC_ALL) { + env.LC_ALL = process.env.LC_ALL; + } + if (process.env.CI) { + env.CI = process.env.CI; + } + if (process.env.NODE_DISABLE_COMPILE_CACHE) { + env.NODE_DISABLE_COMPILE_CACHE = process.env.NODE_DISABLE_COMPILE_CACHE; + } + + return env; +} + +function runCase(testCase) { + const env = buildBenchEnv(); + const result = spawnSync(process.execPath, ["--import", rssHookPath, ...testCase.args], { cwd: repoRoot, env, encoding: "utf8", diff --git a/src/cli/banner-config-lite.ts b/src/cli/banner-config-lite.ts new file mode 100644 index 00000000000..f402b7c61b9 --- /dev/null +++ b/src/cli/banner-config-lite.ts @@ -0,0 +1,24 @@ +import fs from "node:fs"; +import JSON5 from "json5"; +import { resolveConfigPath } from "../config/paths.js"; +import type { TaglineMode } from "./tagline.js"; + +function parseTaglineMode(value: unknown): TaglineMode | undefined { + if (value === "random" || value === "default" || value === "off") { + return value; + } + return undefined; +} + +export function readCliBannerTaglineMode( + env: NodeJS.ProcessEnv = process.env, +): TaglineMode | undefined { + try { + const configPath = resolveConfigPath(env); + const raw = fs.readFileSync(configPath, "utf8"); + const parsed: { cli?: { banner?: { taglineMode?: unknown } } } = JSON5.parse(raw); + return parseTaglineMode(parsed.cli?.banner?.taglineMode); + } catch { + return undefined; + } +} diff --git a/src/cli/banner.test.ts b/src/cli/banner.test.ts index 93e47a750d2..722a574f49f 100644 --- a/src/cli/banner.test.ts +++ b/src/cli/banner.test.ts @@ -1,9 +1,9 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -const loadConfigMock = vi.fn(); +const readCliBannerTaglineModeMock = vi.fn(); -vi.mock("../config/config.js", () => ({ - loadConfig: loadConfigMock, +vi.mock("./banner-config-lite.js", () => ({ + readCliBannerTaglineMode: readCliBannerTaglineModeMock, })); let formatCliBannerLine: typeof import("./banner.js").formatCliBannerLine; @@ -13,15 +13,13 @@ beforeAll(async () => { }); beforeEach(() => { - loadConfigMock.mockReset(); - loadConfigMock.mockReturnValue({}); + readCliBannerTaglineModeMock.mockReset(); + readCliBannerTaglineModeMock.mockReturnValue(undefined); }); describe("formatCliBannerLine", () => { it("hides tagline text when cli.banner.taglineMode is off", () => { - loadConfigMock.mockReturnValue({ - cli: { banner: { taglineMode: "off" } }, - }); + readCliBannerTaglineModeMock.mockReturnValue("off"); const line = formatCliBannerLine("2026.3.7", { commit: "abc1234", @@ -32,9 +30,7 @@ describe("formatCliBannerLine", () => { }); it("uses default tagline when cli.banner.taglineMode is default", () => { - loadConfigMock.mockReturnValue({ - cli: { banner: { taglineMode: "default" } }, - }); + readCliBannerTaglineModeMock.mockReturnValue("default"); const line = formatCliBannerLine("2026.3.7", { commit: "abc1234", @@ -45,9 +41,7 @@ describe("formatCliBannerLine", () => { }); it("prefers explicit tagline mode over config", () => { - loadConfigMock.mockReturnValue({ - cli: { banner: { taglineMode: "off" } }, - }); + readCliBannerTaglineModeMock.mockReturnValue("off"); const line = formatCliBannerLine("2026.3.7", { commit: "abc1234", diff --git a/src/cli/banner.ts b/src/cli/banner.ts index 07bc16abfa0..17487d58904 100644 --- a/src/cli/banner.ts +++ b/src/cli/banner.ts @@ -1,8 +1,8 @@ -import { loadConfig } from "../config/config.js"; import { resolveCommitHash } from "../infra/git-commit.js"; import { visibleWidth } from "../terminal/ansi.js"; import { isRich, theme } from "../terminal/theme.js"; import { hasRootVersionAlias } from "./argv.js"; +import { readCliBannerTaglineMode } from "./banner-config-lite.js"; import { pickTagline, type TaglineMode, type TaglineOptions } from "./tagline.js"; type BannerOptions = TaglineOptions & { @@ -48,12 +48,7 @@ function resolveTaglineMode(options: BannerOptions): TaglineMode | undefined { if (explicit) { return explicit; } - try { - return parseTaglineMode(loadConfig().cli?.banner?.taglineMode); - } catch { - // Fall back to default random behavior when config is missing/invalid. - return undefined; - } + return readCliBannerTaglineMode(options.env); } export function formatCliBannerLine(version: string, options: BannerOptions = {}): string { diff --git a/src/cli/program/command-registry.ts b/src/cli/program/command-registry.ts index ad468878aeb..4b39b1d94a9 100644 --- a/src/cli/program/command-registry.ts +++ b/src/cli/program/command-registry.ts @@ -3,8 +3,15 @@ import { getPrimaryCommand, hasHelpOrVersion } from "../argv.js"; import { reparseProgramFromActionArgs } from "./action-reparse.js"; import { removeCommandByName } from "./command-tree.js"; import type { ProgramContext } from "./context.js"; +import { + type CoreCliCommandDescriptor, + getCoreCliCommandDescriptors, + getCoreCliCommandsWithSubcommands, +} from "./core-command-descriptors.js"; import { registerSubCliCommands } from "./register.subclis.js"; +export { getCoreCliCommandDescriptors, getCoreCliCommandsWithSubcommands }; + type CommandRegisterParams = { program: Command; ctx: ProgramContext; @@ -16,12 +23,6 @@ export type CommandRegistration = { register: (params: CommandRegisterParams) => void; }; -type CoreCliCommandDescriptor = { - name: string; - description: string; - hasSubcommands: boolean; -}; - type CoreCliEntry = { commands: CoreCliCommandDescriptor[]; register: (params: CommandRegisterParams) => Promise | void; @@ -217,34 +218,8 @@ const coreEntries: CoreCliEntry[] = [ }, ]; -function collectCoreCliCommandNames(predicate?: (command: CoreCliCommandDescriptor) => boolean) { - const seen = new Set(); - const names: string[] = []; - for (const entry of coreEntries) { - for (const command of entry.commands) { - if (predicate && !predicate(command)) { - continue; - } - if (seen.has(command.name)) { - continue; - } - seen.add(command.name); - names.push(command.name); - } - } - return names; -} - -export function getCoreCliCommandDescriptors(): ReadonlyArray { - return coreEntries.flatMap((entry) => entry.commands); -} - export function getCoreCliCommandNames(): string[] { - return collectCoreCliCommandNames(); -} - -export function getCoreCliCommandsWithSubcommands(): string[] { - return collectCoreCliCommandNames((command) => command.hasSubcommands); + return getCoreCliCommandDescriptors().map((command) => command.name); } function removeEntryCommands(program: Command, entry: CoreCliEntry) { diff --git a/src/cli/program/core-command-descriptors.ts b/src/cli/program/core-command-descriptors.ts new file mode 100644 index 00000000000..6cad819a1dc --- /dev/null +++ b/src/cli/program/core-command-descriptors.ts @@ -0,0 +1,104 @@ +export type CoreCliCommandDescriptor = { + name: string; + description: string; + hasSubcommands: boolean; +}; + +export const CORE_CLI_COMMAND_DESCRIPTORS = [ + { + name: "setup", + description: "Initialize local config and agent workspace", + hasSubcommands: false, + }, + { + name: "onboard", + description: "Interactive onboarding wizard for gateway, workspace, and skills", + hasSubcommands: false, + }, + { + name: "configure", + description: "Interactive setup wizard for credentials, channels, gateway, and agent defaults", + hasSubcommands: false, + }, + { + name: "config", + description: + "Non-interactive config helpers (get/set/unset/file/validate). Default: starts setup wizard.", + hasSubcommands: true, + }, + { + name: "backup", + description: "Create and verify local backup archives for OpenClaw state", + hasSubcommands: true, + }, + { + name: "doctor", + description: "Health checks + quick fixes for the gateway and channels", + hasSubcommands: false, + }, + { + name: "dashboard", + description: "Open the Control UI with your current token", + hasSubcommands: false, + }, + { + name: "reset", + description: "Reset local config/state (keeps the CLI installed)", + hasSubcommands: false, + }, + { + name: "uninstall", + description: "Uninstall the gateway service + local data (CLI remains)", + hasSubcommands: false, + }, + { + name: "message", + description: "Send, read, and manage messages", + hasSubcommands: true, + }, + { + name: "memory", + description: "Search and reindex memory files", + hasSubcommands: true, + }, + { + name: "agent", + description: "Run one agent turn via the Gateway", + hasSubcommands: false, + }, + { + name: "agents", + description: "Manage isolated agents (workspaces, auth, routing)", + hasSubcommands: true, + }, + { + name: "status", + description: "Show channel health and recent session recipients", + hasSubcommands: false, + }, + { + name: "health", + description: "Fetch health from the running gateway", + hasSubcommands: false, + }, + { + name: "sessions", + description: "List stored conversation sessions", + hasSubcommands: true, + }, + { + name: "browser", + description: "Manage OpenClaw's dedicated browser (Chrome/Chromium)", + hasSubcommands: true, + }, +] as const satisfies ReadonlyArray; + +export function getCoreCliCommandDescriptors(): ReadonlyArray { + return CORE_CLI_COMMAND_DESCRIPTORS; +} + +export function getCoreCliCommandsWithSubcommands(): string[] { + return CORE_CLI_COMMAND_DESCRIPTORS.filter((command) => command.hasSubcommands).map( + (command) => command.name, + ); +} diff --git a/src/cli/program/help.ts b/src/cli/program/help.ts index c22ea7c8322..fc924cec9d3 100644 --- a/src/cli/program/help.ts +++ b/src/cli/program/help.ts @@ -7,9 +7,9 @@ import { hasFlag, hasRootVersionAlias } from "../argv.js"; import { formatCliBannerLine, hasEmittedCliBanner } from "../banner.js"; import { replaceCliName, resolveCliName } from "../cli-name.js"; import { CLI_LOG_LEVEL_VALUES, parseCliLogLevelOption } from "../log-level-option.js"; -import { getCoreCliCommandsWithSubcommands } from "./command-registry.js"; import type { ProgramContext } from "./context.js"; -import { getSubCliCommandsWithSubcommands } from "./register.subclis.js"; +import { getCoreCliCommandsWithSubcommands } from "./core-command-descriptors.js"; +import { getSubCliCommandsWithSubcommands } from "./subcli-descriptors.js"; const CLI_NAME = resolveCliName(); const CLI_NAME_PATTERN = escapeRegExp(CLI_NAME); diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index ad120cc0417..5ace8c10441 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -4,13 +4,17 @@ import { isTruthyEnvValue } from "../../infra/env.js"; import { getPrimaryCommand, hasHelpOrVersion } from "../argv.js"; import { reparseProgramFromActionArgs } from "./action-reparse.js"; import { removeCommand, removeCommandByName } from "./command-tree.js"; +import { + getSubCliCommandsWithSubcommands, + getSubCliEntries as getSubCliEntryDescriptors, + type SubCliDescriptor, +} from "./subcli-descriptors.js"; + +export { getSubCliCommandsWithSubcommands }; type SubCliRegistrar = (program: Command) => Promise | void; -type SubCliEntry = { - name: string; - description: string; - hasSubcommands: boolean; +type SubCliEntry = SubCliDescriptor & { register: SubCliRegistrar; }; @@ -309,12 +313,8 @@ const entries: SubCliEntry[] = [ }, ]; -export function getSubCliEntries(): SubCliEntry[] { - return entries; -} - -export function getSubCliCommandsWithSubcommands(): string[] { - return entries.filter((entry) => entry.hasSubcommands).map((entry) => entry.name); +export function getSubCliEntries(): ReadonlyArray { + return getSubCliEntryDescriptors(); } export async function registerSubCliByName(program: Command, name: string): Promise { diff --git a/src/cli/program/root-help.ts b/src/cli/program/root-help.ts index b80302e9818..500dbe3b039 100644 --- a/src/cli/program/root-help.ts +++ b/src/cli/program/root-help.ts @@ -1,8 +1,8 @@ import { Command } from "commander"; import { VERSION } from "../../version.js"; -import { getCoreCliCommandDescriptors } from "./command-registry.js"; +import { getCoreCliCommandDescriptors } from "./core-command-descriptors.js"; import { configureProgramHelp } from "./help.js"; -import { getSubCliEntries } from "./register.subclis.js"; +import { getSubCliEntries } from "./subcli-descriptors.js"; function buildRootHelpProgram(): Command { const program = new Command(); diff --git a/src/cli/program/subcli-descriptors.ts b/src/cli/program/subcli-descriptors.ts new file mode 100644 index 00000000000..4011e706b2b --- /dev/null +++ b/src/cli/program/subcli-descriptors.ts @@ -0,0 +1,144 @@ +export type SubCliDescriptor = { + name: string; + description: string; + hasSubcommands: boolean; +}; + +export const SUB_CLI_DESCRIPTORS = [ + { name: "acp", description: "Agent Control Protocol tools", hasSubcommands: true }, + { + name: "gateway", + description: "Run, inspect, and query the WebSocket Gateway", + hasSubcommands: true, + }, + { name: "daemon", description: "Gateway service (legacy alias)", hasSubcommands: true }, + { name: "logs", description: "Tail gateway file logs via RPC", hasSubcommands: false }, + { + name: "system", + description: "System events, heartbeat, and presence", + hasSubcommands: true, + }, + { + name: "models", + description: "Discover, scan, and configure models", + hasSubcommands: true, + }, + { + name: "approvals", + description: "Manage exec approvals (gateway or node host)", + hasSubcommands: true, + }, + { + name: "nodes", + description: "Manage gateway-owned node pairing and node commands", + hasSubcommands: true, + }, + { + name: "devices", + description: "Device pairing + token management", + hasSubcommands: true, + }, + { + name: "node", + description: "Run and manage the headless node host service", + hasSubcommands: true, + }, + { + name: "sandbox", + description: "Manage sandbox containers for agent isolation", + hasSubcommands: true, + }, + { + name: "tui", + description: "Open a terminal UI connected to the Gateway", + hasSubcommands: false, + }, + { + name: "cron", + description: "Manage cron jobs via the Gateway scheduler", + hasSubcommands: true, + }, + { + name: "dns", + description: "DNS helpers for wide-area discovery (Tailscale + CoreDNS)", + hasSubcommands: true, + }, + { + name: "docs", + description: "Search the live OpenClaw docs", + hasSubcommands: false, + }, + { + name: "hooks", + description: "Manage internal agent hooks", + hasSubcommands: true, + }, + { + name: "webhooks", + description: "Webhook helpers and integrations", + hasSubcommands: true, + }, + { + name: "qr", + description: "Generate iOS pairing QR/setup code", + hasSubcommands: false, + }, + { + name: "clawbot", + description: "Legacy clawbot command aliases", + hasSubcommands: true, + }, + { + name: "pairing", + description: "Secure DM pairing (approve inbound requests)", + hasSubcommands: true, + }, + { + name: "plugins", + description: "Manage OpenClaw plugins and extensions", + hasSubcommands: true, + }, + { + name: "channels", + description: "Manage connected chat channels (Telegram, Discord, etc.)", + hasSubcommands: true, + }, + { + name: "directory", + description: "Lookup contact and group IDs (self, peers, groups) for supported chat channels", + hasSubcommands: true, + }, + { + name: "security", + description: "Security tools and local config audits", + hasSubcommands: true, + }, + { + name: "secrets", + description: "Secrets runtime reload controls", + hasSubcommands: true, + }, + { + name: "skills", + description: "List and inspect available skills", + hasSubcommands: true, + }, + { + name: "update", + description: "Update OpenClaw and inspect update channel status", + hasSubcommands: true, + }, + { + name: "completion", + description: "Generate shell completion script", + hasSubcommands: false, + }, +] as const satisfies ReadonlyArray; + +export function getSubCliEntries(): ReadonlyArray { + return SUB_CLI_DESCRIPTORS; +} + +export function getSubCliCommandsWithSubcommands(): string[] { + return SUB_CLI_DESCRIPTORS.filter((entry) => entry.hasSubcommands).map((entry) => entry.name); +} From 83ee5c03285317725d56f0db00101e2c124be285 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 18:20:37 -0700 Subject: [PATCH 091/943] perf(status): defer heavy startup loading --- src/channels/config-presence.ts | 94 ++++++++++++++++ src/cli/program/preaction.test.ts | 14 +++ src/cli/program/preaction.ts | 12 ++- src/cli/program/routes.ts | 6 +- src/cli/route.test.ts | 4 +- src/commands/status.command.ts | 14 ++- src/commands/status.scan.test.ts | 173 ++++++++++++++++++++++++++++++ src/commands/status.scan.ts | 12 +++ src/commands/status.summary.ts | 16 +-- src/commands/status.test.ts | 2 +- src/security/audit.ts | 3 +- 11 files changed, 334 insertions(+), 16 deletions(-) create mode 100644 src/channels/config-presence.ts diff --git a/src/channels/config-presence.ts b/src/channels/config-presence.ts new file mode 100644 index 00000000000..792aa545a54 --- /dev/null +++ b/src/channels/config-presence.ts @@ -0,0 +1,94 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveOAuthDir } from "../config/paths.js"; +import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; + +const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); + +const CHANNEL_ENV_PREFIXES = [ + "BLUEBUBBLES_", + "DISCORD_", + "GOOGLECHAT_", + "IRC_", + "LINE_", + "MATRIX_", + "MSTEAMS_", + "SIGNAL_", + "SLACK_", + "TELEGRAM_", + "WHATSAPP_", + "ZALOUSER_", + "ZALO_", +] as const; + +function hasNonEmptyString(value: unknown): boolean { + return typeof value === "string" && value.trim().length > 0; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function recordHasKeys(value: unknown): boolean { + return isRecord(value) && Object.keys(value).length > 0; +} + +function hasWhatsAppAuthState(env: NodeJS.ProcessEnv): boolean { + try { + const oauthDir = resolveOAuthDir(env); + const legacyCreds = path.join(oauthDir, "creds.json"); + if (fs.existsSync(legacyCreds)) { + return true; + } + + const accountsRoot = path.join(oauthDir, "whatsapp"); + const defaultCreds = path.join(accountsRoot, DEFAULT_ACCOUNT_ID, "creds.json"); + if (fs.existsSync(defaultCreds)) { + return true; + } + + const entries = fs.readdirSync(accountsRoot, { withFileTypes: true }); + return entries.some((entry) => { + if (!entry.isDirectory()) { + return false; + } + return fs.existsSync(path.join(accountsRoot, entry.name, "creds.json")); + }); + } catch { + return false; + } +} + +function hasEnvConfiguredChannel(env: NodeJS.ProcessEnv): boolean { + for (const [key, value] of Object.entries(env)) { + if (!hasNonEmptyString(value)) { + continue; + } + if ( + CHANNEL_ENV_PREFIXES.some((prefix) => key.startsWith(prefix)) || + key === "TELEGRAM_BOT_TOKEN" + ) { + return true; + } + } + return hasWhatsAppAuthState(env); +} + +export function hasPotentialConfiguredChannels( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): boolean { + const channels = isRecord(cfg.channels) ? cfg.channels : null; + if (channels) { + for (const [key, value] of Object.entries(channels)) { + if (IGNORED_CHANNEL_CONFIG_KEYS.has(key)) { + continue; + } + if (recordHasKeys(value)) { + return true; + } + } + } + return hasEnvConfiguredChannel(env); +} diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index 2a1367870c6..2376e97100f 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -190,6 +190,19 @@ describe("registerPreActionHooks", () => { }); it("applies --json stdout suppression only for explicit JSON output commands", async () => { + await runPreAction({ + parseArgv: ["status"], + processArgv: ["node", "openclaw", "status", "--json"], + }); + + expect(ensureConfigReadyMock).toHaveBeenCalledWith({ + runtime: runtimeMock, + commandPath: ["status"], + suppressDoctorStdout: true, + }); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); + + vi.clearAllMocks(); await runPreAction({ parseArgv: ["update", "status", "--json"], processArgv: ["node", "openclaw", "update", "status", "--json"], @@ -200,6 +213,7 @@ describe("registerPreActionHooks", () => { commandPath: ["update", "status"], suppressDoctorStdout: true, }); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); vi.clearAllMocks(); await runPreAction({ diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index ccd84e3201e..19659f97c7e 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -71,6 +71,16 @@ function resolvePluginRegistryScope(commandPath: string[]): "channels" | "all" { return commandPath[0] === "status" || commandPath[0] === "health" ? "channels" : "all"; } +function shouldLoadPluginsForCommand(commandPath: string[], argv: string[]): boolean { + if (!PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) { + return false; + } + if ((commandPath[0] === "status" || commandPath[0] === "health") && hasFlag(argv, "--json")) { + return false; + } + return true; +} + function getRootCommand(command: Command): Command { let current = command; while (current.parent) { @@ -138,7 +148,7 @@ export function registerPreActionHooks(program: Command, programVersion: string) ...(suppressDoctorStdout ? { suppressDoctorStdout: true } : {}), }); // Load plugins for commands that need channel access - if (PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) { + if (shouldLoadPluginsForCommand(commandPath, argv)) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); ensurePluginRegistryLoaded({ scope: resolvePluginRegistryScope(commandPath) }); } diff --git a/src/cli/program/routes.ts b/src/cli/program/routes.ts index cea5fcb8138..52e0d8f8446 100644 --- a/src/cli/program/routes.ts +++ b/src/cli/program/routes.ts @@ -34,9 +34,9 @@ const routeHealth: RouteSpec = { const routeStatus: RouteSpec = { match: (path) => path[0] === "status", - // Status runs security audit with channel checks in both text and JSON output, - // so plugin registry must be ready for consistent findings. - loadPlugins: true, + // `status --json` can defer channel plugin loading until config/env inspection + // proves it is needed, which keeps the fast-path startup lightweight. + loadPlugins: (argv) => !hasFlag(argv, "--json"), run: async (argv) => { const json = hasFlag(argv, "--json"); const deep = hasFlag(argv, "--deep"); diff --git a/src/cli/route.test.ts b/src/cli/route.test.ts index 93516906ad0..9e7c6c7c110 100644 --- a/src/cli/route.test.ts +++ b/src/cli/route.test.ts @@ -37,7 +37,7 @@ describe("tryRouteCli", () => { vi.resetModules(); ({ tryRouteCli } = await import("./route.js")); findRoutedCommandMock.mockReturnValue({ - loadPlugins: true, + loadPlugins: (argv: string[]) => !argv.includes("--json"), run: runRouteMock, }); }); @@ -59,7 +59,7 @@ describe("tryRouteCli", () => { suppressDoctorStdout: true, }), ); - expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels" }); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); }); it("does not pass suppressDoctorStdout for routed non-json commands", async () => { diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 7e68424c5a9..92702bac66e 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -5,7 +5,6 @@ import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { info } from "../globals.js"; import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js"; -import { formatUsageReportLines, loadProviderUsageSummary } from "../infra/provider-usage.js"; import { normalizeUpdateChannel, resolveUpdateChannelDisplay } from "../infra/update-channels.js"; import { formatGitInstallLabel } from "../infra/update-check.js"; import { @@ -37,6 +36,13 @@ import { resolveUpdateAvailability, } from "./status.update.js"; +let providerUsagePromise: Promise | undefined; + +function loadProviderUsage() { + providerUsagePromise ??= import("../infra/provider-usage.js"); + return providerUsagePromise; +} + function resolvePairingRecoveryContext(params: { error?: string | null; closeReason?: string | null; @@ -138,7 +144,10 @@ export async function statusCommand( indeterminate: true, enabled: opts.json !== true, }, - async () => await loadProviderUsageSummary({ timeoutMs: opts.timeoutMs }), + async () => { + const { loadProviderUsageSummary } = await loadProviderUsage(); + return await loadProviderUsageSummary({ timeoutMs: opts.timeoutMs }); + }, ) : undefined; const health: HealthSummary | undefined = opts.deep @@ -658,6 +667,7 @@ export async function statusCommand( } if (usage) { + const { formatUsageReportLines } = await loadProviderUsage(); runtime.log(""); runtime.log(theme.heading("Usage")); for (const line of formatUsageReportLines(usage)) { diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 6592b84c864..9d3399997bf 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({ buildGatewayConnectionDetails: vi.fn(), probeGateway: vi.fn(), resolveGatewayProbeAuthResolution: vi.fn(), + ensurePluginRegistryLoaded: vi.fn(), })); vi.mock("../cli/progress.js", () => ({ @@ -70,6 +71,10 @@ vi.mock("../process/exec.js", () => ({ runExec: vi.fn(), })); +vi.mock("../cli/plugin-registry.js", () => ({ + ensurePluginRegistryLoaded: mocks.ensurePluginRegistryLoaded, +})); + import { scanStatus } from "./status.scan.js"; describe("scanStatus", () => { @@ -135,4 +140,172 @@ describe("scanStatus", () => { }), ); }); + + it("skips channel plugin preload for status --json with no channel config", async () => { + mocks.readBestEffortConfig.mockResolvedValue({ + session: {}, + plugins: { enabled: false }, + gateway: {}, + }); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { + session: {}, + plugins: { enabled: false }, + gateway: {}, + }, + diagnostics: [], + }); + mocks.getUpdateCheckResult.mockResolvedValue({ + installKind: "git", + git: null, + registry: null, + }); + mocks.getAgentLocalStatuses.mockResolvedValue({ + defaultId: "main", + agents: [], + }); + mocks.getStatusSummary.mockResolvedValue({ + linkChannel: undefined, + sessions: { count: 0, paths: [], defaults: {}, recent: [] }, + }); + mocks.buildGatewayConnectionDetails.mockReturnValue({ + url: "ws://127.0.0.1:18789", + urlSource: "default", + }); + mocks.resolveGatewayProbeAuthResolution.mockReturnValue({ + auth: {}, + warning: undefined, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + await scanStatus({ json: true }, {} as never); + + expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled(); + }); + + it("preloads channel plugins for status --json when channel config exists", async () => { + mocks.readBestEffortConfig.mockResolvedValue({ + session: {}, + plugins: { enabled: false }, + gateway: {}, + channels: { telegram: { enabled: false } }, + }); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { + session: {}, + plugins: { enabled: false }, + gateway: {}, + channels: { telegram: { enabled: false } }, + }, + diagnostics: [], + }); + mocks.getUpdateCheckResult.mockResolvedValue({ + installKind: "git", + git: null, + registry: null, + }); + mocks.getAgentLocalStatuses.mockResolvedValue({ + defaultId: "main", + agents: [], + }); + mocks.getStatusSummary.mockResolvedValue({ + linkChannel: { linked: false }, + sessions: { count: 0, paths: [], defaults: {}, recent: [] }, + }); + mocks.buildGatewayConnectionDetails.mockReturnValue({ + url: "ws://127.0.0.1:18789", + urlSource: "default", + }); + mocks.resolveGatewayProbeAuthResolution.mockReturnValue({ + auth: {}, + warning: undefined, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + await scanStatus({ json: true }, {} as never); + + expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "channels" }); + }); + + it("preloads channel plugins for status --json when channel auth is env-only", async () => { + const prevMatrixToken = process.env.MATRIX_ACCESS_TOKEN; + process.env.MATRIX_ACCESS_TOKEN = "token"; + mocks.readBestEffortConfig.mockResolvedValue({ + session: {}, + plugins: { enabled: false }, + gateway: {}, + }); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { + session: {}, + plugins: { enabled: false }, + gateway: {}, + }, + diagnostics: [], + }); + mocks.getUpdateCheckResult.mockResolvedValue({ + installKind: "git", + git: null, + registry: null, + }); + mocks.getAgentLocalStatuses.mockResolvedValue({ + defaultId: "main", + agents: [], + }); + mocks.getStatusSummary.mockResolvedValue({ + linkChannel: { linked: false }, + sessions: { count: 0, paths: [], defaults: {}, recent: [] }, + }); + mocks.buildGatewayConnectionDetails.mockReturnValue({ + url: "ws://127.0.0.1:18789", + urlSource: "default", + }); + mocks.resolveGatewayProbeAuthResolution.mockReturnValue({ + auth: {}, + warning: undefined, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + try { + await scanStatus({ json: true }, {} as never); + } finally { + if (prevMatrixToken === undefined) { + delete process.env.MATRIX_ACCESS_TOKEN; + } else { + process.env.MATRIX_ACCESS_TOKEN = prevMatrixToken; + } + } + + expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "channels" }); + }); }); diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 38e15e6417b..0de308f17f2 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -1,3 +1,4 @@ +import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; import { getStatusCommandSecretTargetIds } from "../cli/command-secret-targets.js"; import { withProgress } from "../cli/progress.js"; @@ -46,6 +47,13 @@ type GatewayProbeSnapshot = { gatewayProbe: Awaited> | null; }; +let pluginRegistryModulePromise: Promise | undefined; + +function loadPluginRegistryModule() { + pluginRegistryModulePromise ??= import("../cli/plugin-registry.js"); + return pluginRegistryModulePromise; +} + function deferResult(promise: Promise): Promise> { return promise.then( (value) => ({ ok: true, value }), @@ -191,6 +199,10 @@ async function scanStatusJsonFast(opts: { targetIds: getStatusCommandSecretTargetIds(), mode: "summary", }); + if (hasPotentialConfiguredChannels(cfg)) { + const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); + ensurePluginRegistryLoaded({ scope: "channels" }); + } const osSummary = resolveOsSummary(); const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; const updateTimeoutMs = opts.all ? 6500 : 2500; diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index b84bada07ff..e1347a90b5a 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -1,6 +1,7 @@ import { resolveContextTokensForModel } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; +import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { @@ -89,7 +90,8 @@ export async function getStatusSummary( ): Promise { const { includeSensitive = true } = options; const cfg = options.config ?? loadConfig(); - const linkContext = await resolveLinkChannelContext(cfg); + const needsChannelPlugins = hasPotentialConfiguredChannels(cfg); + const linkContext = needsChannelPlugins ? await resolveLinkChannelContext(cfg) : null; const agentList = listAgentsForGateway(cfg); const heartbeatAgents: HeartbeatStatus[] = agentList.agents.map((agent) => { const summary = resolveHeartbeatSummaryForAgent(cfg, agent.id); @@ -100,11 +102,13 @@ export async function getStatusSummary( everyMs: summary.everyMs, } satisfies HeartbeatStatus; }); - const channelSummary = await buildChannelSummary(cfg, { - colorize: true, - includeAllowFrom: true, - sourceConfig: options.sourceConfig, - }); + const channelSummary = needsChannelPlugins + ? await buildChannelSummary(cfg, { + colorize: true, + includeAllowFrom: true, + sourceConfig: options.sourceConfig, + }) + : []; const mainSessionKey = resolveMainSessionKey(cfg); const queuedSystemEvents = peekSystemEvents(mainSessionKey); diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index c40693302ac..5cc71b6e950 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -398,7 +398,7 @@ describe("statusCommand", () => { it("prints JSON when requested", async () => { await statusCommand({ json: true }, runtime as never); const payload = JSON.parse(String(runtimeLogMock.mock.calls[0]?.[0])); - expect(payload.linkChannel.linked).toBe(true); + expect(payload.linkChannel).toBeUndefined(); expect(payload.memory.agentId).toBe("main"); expect(payload.memoryPlugin.enabled).toBe(true); expect(payload.memoryPlugin.slot).toBe("memory-core"); diff --git a/src/security/audit.ts b/src/security/audit.ts index 113ec2bd067..dbbfb9651be 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -5,6 +5,7 @@ import { execDockerRaw } from "../agents/sandbox/docker.js"; import { redactCdpUrl } from "../browser/cdp.helpers.js"; import { resolveBrowserConfig, resolveProfile } from "../browser/config.js"; import { resolveBrowserControlAuth } from "../browser/control-auth.js"; +import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import { listChannelPlugins } from "../channels/plugins/index.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js"; @@ -1226,7 +1227,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise Date: Sun, 15 Mar 2026 18:20:42 -0700 Subject: [PATCH 092/943] fix(matrix): assert outbound runtime hooks --- extensions/matrix/src/channel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 0522590356a..a6a33a7f627 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -379,7 +379,7 @@ export const matrixPlugin: ChannelPlugin = { }, outbound: { deliveryMode: "direct", - chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit), + chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText!(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, sendText: async (params) => (await loadMatrixChannelRuntime()).matrixOutbound.sendText!(params), From 71a69e533791cc943fe2210daf64f35de86b54f0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 18:22:48 -0700 Subject: [PATCH 093/943] refactor: extend setup wizard account resolution --- src/channels/plugins/setup-wizard.ts | 37 +++++++++++++++++++++------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/src/channels/plugins/setup-wizard.ts b/src/channels/plugins/setup-wizard.ts index f71d1802aa3..9f4f1fdb5cc 100644 --- a/src/channels/plugins/setup-wizard.ts +++ b/src/channels/plugins/setup-wizard.ts @@ -248,6 +248,15 @@ export type ChannelSetupWizard = { status: ChannelSetupWizardStatus; introNote?: ChannelSetupWizardNote; envShortcut?: ChannelSetupWizardEnvShortcut; + resolveAccountIdForConfigure?: (params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + options?: ChannelOnboardingConfigureContext["options"]; + accountOverride?: string; + shouldPromptAccountIds: boolean; + listAccountIds: ChannelSetupWizardPlugin["config"]["listAccountIds"]; + defaultAccountId: string; + }) => string | Promise; resolveShouldPromptAccountIds?: (params: { cfg: OpenClawConfig; options?: ChannelOnboardingConfigureContext["options"]; @@ -416,15 +425,25 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { options, shouldPromptAccountIds, }) ?? shouldPromptAccountIds; - const accountId = await resolveAccountIdForConfigure({ - cfg, - prompter, - label: plugin.meta.label, - accountOverride: accountOverrides[plugin.id], - shouldPromptAccountIds: resolvedShouldPromptAccountIds, - listAccountIds: plugin.config.listAccountIds, - defaultAccountId, - }); + const accountId = await (wizard.resolveAccountIdForConfigure + ? wizard.resolveAccountIdForConfigure({ + cfg, + prompter, + options, + accountOverride: accountOverrides[plugin.id], + shouldPromptAccountIds: resolvedShouldPromptAccountIds, + listAccountIds: plugin.config.listAccountIds, + defaultAccountId, + }) + : resolveAccountIdForConfigure({ + cfg, + prompter, + label: plugin.meta.label, + accountOverride: accountOverrides[plugin.id], + shouldPromptAccountIds: resolvedShouldPromptAccountIds, + listAccountIds: plugin.config.listAccountIds, + defaultAccountId, + })); let next = cfg; let credentialValues = collectCredentialValues({ From 40be12db966a37abbffbffa65ecd482ef95fb9f3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 18:22:57 -0700 Subject: [PATCH 094/943] refactor: move feishu zalo zalouser to setup wizard --- extensions/feishu/src/channel.ts | 63 +-- .../feishu/src/onboarding.status.test.ts | 12 +- extensions/feishu/src/onboarding.test.ts | 18 +- .../src/{onboarding.ts => setup-surface.ts} | 371 ++++++++++-------- extensions/zalo/src/channel.ts | 56 +-- extensions/zalo/src/onboarding.status.test.ts | 12 +- extensions/zalo/src/setup-surface.test.ts | 60 +++ .../src/{onboarding.ts => setup-surface.ts} | 210 ++++++---- extensions/zalouser/src/channel.ts | 40 +- extensions/zalouser/src/setup-surface.test.ts | 86 ++++ .../src/{onboarding.ts => setup-surface.ts} | 283 +++++++------ src/plugin-sdk/feishu.ts | 8 +- src/plugin-sdk/zalo.ts | 6 +- src/plugin-sdk/zalouser.ts | 10 +- 14 files changed, 675 insertions(+), 560 deletions(-) rename extensions/feishu/src/{onboarding.ts => setup-surface.ts} (62%) create mode 100644 extensions/zalo/src/setup-surface.test.ts rename extensions/zalo/src/{onboarding.ts => setup-surface.ts} (65%) create mode 100644 extensions/zalouser/src/setup-surface.test.ts rename extensions/zalouser/src/{onboarding.ts => setup-surface.ts} (57%) diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index ecfd27194b7..7d8560d5182 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -25,6 +25,7 @@ import { FeishuConfigSchema } from "./config-schema.js"; import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js"; import { resolveFeishuGroupToolPolicy } from "./policy.js"; import { getFeishuRuntime } from "./runtime.js"; +import { feishuSetupAdapter, feishuSetupWizard } from "./setup-surface.js"; import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js"; import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js"; @@ -43,44 +44,6 @@ async function loadFeishuChannelRuntime() { return await import("./channel.runtime.js"); } -const feishuOnboarding = { - channel: "feishu", - getStatus: async (ctx) => - (await loadFeishuChannelRuntime()).feishuOnboardingAdapter.getStatus(ctx), - configure: async (ctx) => - (await loadFeishuChannelRuntime()).feishuOnboardingAdapter.configure(ctx), - dmPolicy: { - label: "Feishu", - channel: "feishu", - policyKey: "channels.feishu.dmPolicy", - allowFromKey: "channels.feishu.allowFrom", - getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => ({ - ...cfg, - channels: { - ...cfg.channels, - feishu: { - ...cfg.channels?.feishu, - dmPolicy: policy, - }, - }, - }), - promptAllowFrom: async ({ cfg, prompter, accountId }) => - (await loadFeishuChannelRuntime()).feishuOnboardingAdapter.dmPolicy!.promptAllowFrom!({ - cfg, - prompter, - accountId, - }), - }, - disable: (cfg) => ({ - ...cfg, - channels: { - ...cfg.channels, - feishu: { ...cfg.channels?.feishu, enabled: false }, - }, - }), -} satisfies ChannelPlugin["onboarding"]; - function setFeishuNamedAccountEnabled( cfg: ClawdbotConfig, accountId: string, @@ -429,28 +392,8 @@ export const feishuPlugin: ChannelPlugin = { }); }, }, - setup: { - resolveAccountId: () => DEFAULT_ACCOUNT_ID, - applyAccountConfig: ({ cfg, accountId }) => { - const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID; - - if (isDefault) { - return { - ...cfg, - channels: { - ...cfg.channels, - feishu: { - ...cfg.channels?.feishu, - enabled: true, - }, - }, - }; - } - - return setFeishuNamedAccountEnabled(cfg, accountId, true); - }, - }, - onboarding: feishuOnboarding, + setup: feishuSetupAdapter, + setupWizard: feishuSetupWizard, messaging: { normalizeTarget: (raw) => normalizeFeishuTarget(raw) ?? undefined, targetResolver: { diff --git a/extensions/feishu/src/onboarding.status.test.ts b/extensions/feishu/src/onboarding.status.test.ts index eda2bafa242..4f3b853a1e2 100644 --- a/extensions/feishu/src/onboarding.status.test.ts +++ b/extensions/feishu/src/onboarding.status.test.ts @@ -1,10 +1,16 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/feishu"; import { describe, expect, it } from "vitest"; -import { feishuOnboardingAdapter } from "./onboarding.js"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { feishuPlugin } from "./channel.js"; -describe("feishu onboarding status", () => { +const feishuConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: feishuPlugin, + wizard: feishuPlugin.setupWizard!, +}); + +describe("feishu setup wizard status", () => { it("treats SecretRef appSecret as configured when appId is present", async () => { - const status = await feishuOnboardingAdapter.getStatus({ + const status = await feishuConfigureAdapter.getStatus({ cfg: { channels: { feishu: { diff --git a/extensions/feishu/src/onboarding.test.ts b/extensions/feishu/src/onboarding.test.ts index d3ace4faae0..2a444964442 100644 --- a/extensions/feishu/src/onboarding.test.ts +++ b/extensions/feishu/src/onboarding.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; vi.mock("./probe.js", () => ({ probeFeishu: vi.fn(async () => ({ ok: false, error: "mocked" })), })); -import { feishuOnboardingAdapter } from "./onboarding.js"; +import { feishuPlugin } from "./channel.js"; const baseConfigureContext = { runtime: {} as never, @@ -42,7 +43,7 @@ async function withEnvVars(values: Record, run: () = } async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: string }) { - return await feishuOnboardingAdapter.getStatus({ + return await feishuConfigureAdapter.getStatus({ cfg: { channels: { feishu: { @@ -55,7 +56,12 @@ async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: st }); } -describe("feishuOnboardingAdapter.configure", () => { +const feishuConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: feishuPlugin, + wizard: feishuPlugin.setupWizard!, +}); + +describe("feishu setup wizard", () => { it("does not throw when config appId/appSecret are SecretRef objects", async () => { const text = vi .fn() @@ -73,7 +79,7 @@ describe("feishuOnboardingAdapter.configure", () => { } as never; await expect( - feishuOnboardingAdapter.configure({ + feishuConfigureAdapter.configure({ cfg: { channels: { feishu: { @@ -89,9 +95,9 @@ describe("feishuOnboardingAdapter.configure", () => { }); }); -describe("feishuOnboardingAdapter.getStatus", () => { +describe("feishu setup wizard status", () => { it("does not fallback to top-level appId when account explicitly sets empty appId", async () => { - const status = await feishuOnboardingAdapter.getStatus({ + const status = await feishuConfigureAdapter.getStatus({ cfg: { channels: { feishu: { diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/setup-surface.ts similarity index 62% rename from extensions/feishu/src/onboarding.ts rename to extensions/feishu/src/setup-surface.ts index 24d3bbcc413..1191a08e4e9 100644 --- a/extensions/feishu/src/onboarding.ts +++ b/extensions/feishu/src/setup-surface.ts @@ -1,24 +1,22 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, - ClawdbotConfig, - DmPolicy, - SecretInput, - WizardPrompter, -} from "openclaw/plugin-sdk/feishu"; +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { buildSingleChannelSecretPromptState, - DEFAULT_ACCOUNT_ID, - formatDocsLink, - hasConfiguredSecretInput, mergeAllowFromEntries, promptSingleChannelSecretInput, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, splitOnboardingEntries, -} from "openclaw/plugin-sdk/feishu"; -import { resolveFeishuCredentials } from "./accounts.js"; +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DmPolicy } from "../../../src/config/types.js"; +import type { SecretInput } from "../../../src/config/types.secrets.js"; +import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { listFeishuAccountIds, resolveFeishuCredentials } from "./accounts.js"; import { probeFeishu } from "./probe.js"; import type { FeishuConfig } from "./types.js"; @@ -32,26 +30,117 @@ function normalizeString(value: unknown): string | undefined { return trimmed || undefined; } -function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel: "feishu", - dmPolicy, - }) as ClawdbotConfig; +function setFeishuNamedAccountEnabled( + cfg: OpenClawConfig, + accountId: string, + enabled: boolean, +): OpenClawConfig { + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...feishuCfg, + accounts: { + ...feishuCfg?.accounts, + [accountId]: { + ...feishuCfg?.accounts?.[accountId], + enabled, + }, + }, + }, + }, + }; } -function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig { +function setFeishuDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { + return setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy, + }) as OpenClawConfig; +} + +function setFeishuAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { return setTopLevelChannelAllowFrom({ cfg, - channel: "feishu", + channel, allowFrom, - }) as ClawdbotConfig; + }) as OpenClawConfig; +} + +function setFeishuGroupPolicy( + cfg: OpenClawConfig, + groupPolicy: "open" | "allowlist" | "disabled", +): OpenClawConfig { + return setTopLevelChannelGroupPolicy({ + cfg, + channel, + groupPolicy, + enabled: true, + }) as OpenClawConfig; +} + +function setFeishuGroupAllowFrom(cfg: OpenClawConfig, groupAllowFrom: string[]): OpenClawConfig { + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + groupAllowFrom, + }, + }, + }; +} + +function isFeishuConfigured(cfg: OpenClawConfig): boolean { + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + + const isAppIdConfigured = (value: unknown): boolean => { + const asString = normalizeString(value); + if (asString) { + return true; + } + if (!value || typeof value !== "object") { + return false; + } + const rec = value as Record; + const source = normalizeString(rec.source)?.toLowerCase(); + const id = normalizeString(rec.id); + if (source === "env" && id) { + return Boolean(normalizeString(process.env[id])); + } + return hasConfiguredSecretInput(value); + }; + + const topLevelConfigured = Boolean( + isAppIdConfigured(feishuCfg?.appId) && hasConfiguredSecretInput(feishuCfg?.appSecret), + ); + + const accountConfigured = Object.values(feishuCfg?.accounts ?? {}).some((account) => { + if (!account || typeof account !== "object") { + return false; + } + const hasOwnAppId = Object.prototype.hasOwnProperty.call(account, "appId"); + const hasOwnAppSecret = Object.prototype.hasOwnProperty.call(account, "appSecret"); + const accountAppIdConfigured = hasOwnAppId + ? isAppIdConfigured((account as Record).appId) + : isAppIdConfigured(feishuCfg?.appId); + const accountSecretConfigured = hasOwnAppSecret + ? hasConfiguredSecretInput((account as Record).appSecret) + : hasConfiguredSecretInput(feishuCfg?.appSecret); + return Boolean(accountAppIdConfigured && accountSecretConfigured); + }); + + return topLevelConfigured || accountConfigured; } async function promptFeishuAllowFrom(params: { - cfg: ClawdbotConfig; - prompter: WizardPrompter; -}): Promise { + cfg: OpenClawConfig; + prompter: Parameters>[0]["prompter"]; +}): Promise { const existing = params.cfg.channels?.feishu?.allowFrom ?? []; await params.prompter.note( [ @@ -82,7 +171,9 @@ async function promptFeishuAllowFrom(params: { } } -async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise { +async function noteFeishuCredentialHelp( + prompter: Parameters>[0]["prompter"], +): Promise { await prompter.note( [ "1) Go to Feishu Open Platform (open.feishu.cn)", @@ -98,131 +189,82 @@ async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise } async function promptFeishuAppId(params: { - prompter: WizardPrompter; + prompter: Parameters>[0]["prompter"]; initialValue?: string; }): Promise { - const appId = String( + return String( await params.prompter.text({ message: "Enter Feishu App ID", initialValue: params.initialValue, validate: (value) => (value?.trim() ? undefined : "Required"), }), ).trim(); - return appId; } -function setFeishuGroupPolicy( - cfg: ClawdbotConfig, - groupPolicy: "open" | "allowlist" | "disabled", -): ClawdbotConfig { - return setTopLevelChannelGroupPolicy({ - cfg, - channel: "feishu", - groupPolicy, - enabled: true, - }) as ClawdbotConfig; -} - -function setFeishuGroupAllowFrom(cfg: ClawdbotConfig, groupAllowFrom: string[]): ClawdbotConfig { - return { - ...cfg, - channels: { - ...cfg.channels, - feishu: { - ...cfg.channels?.feishu, - groupAllowFrom, - }, - }, - }; -} - -const dmPolicy: ChannelOnboardingDmPolicy = { +const feishuDmPolicy: ChannelOnboardingDmPolicy = { label: "Feishu", channel, policyKey: "channels.feishu.dmPolicy", allowFromKey: "channels.feishu.allowFrom", getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg, policy), + setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg as OpenClawConfig, policy), promptAllowFrom: promptFeishuAllowFrom, }; -export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - - const isAppIdConfigured = (value: unknown): boolean => { - const asString = normalizeString(value); - if (asString) { - return true; - } - if (!value || typeof value !== "object") { - return false; - } - const rec = value as Record; - const source = normalizeString(rec.source)?.toLowerCase(); - const id = normalizeString(rec.id); - if (source === "env" && id) { - return Boolean(normalizeString(process.env[id])); - } - return hasConfiguredSecretInput(value); - }; - - const topLevelConfigured = Boolean( - isAppIdConfigured(feishuCfg?.appId) && hasConfiguredSecretInput(feishuCfg?.appSecret), - ); - - const accountConfigured = Object.values(feishuCfg?.accounts ?? {}).some((account) => { - if (!account || typeof account !== "object") { - return false; - } - const hasOwnAppId = Object.prototype.hasOwnProperty.call(account, "appId"); - const hasOwnAppSecret = Object.prototype.hasOwnProperty.call(account, "appSecret"); - const accountAppIdConfigured = hasOwnAppId - ? isAppIdConfigured((account as Record).appId) - : isAppIdConfigured(feishuCfg?.appId); - const accountSecretConfigured = hasOwnAppSecret - ? hasConfiguredSecretInput((account as Record).appSecret) - : hasConfiguredSecretInput(feishuCfg?.appSecret); - return Boolean(accountAppIdConfigured && accountSecretConfigured); - }); - - const configured = topLevelConfigured || accountConfigured; - const resolvedCredentials = resolveFeishuCredentials(feishuCfg, { - allowUnresolvedSecretRef: true, - }); - - // Try to probe if configured - let probeResult = null; - if (configured && resolvedCredentials) { - try { - probeResult = await probeFeishu(resolvedCredentials); - } catch { - // Ignore probe errors - } +export const feishuSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountConfig: ({ cfg, accountId }) => { + const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID; + if (isDefault) { + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + enabled: true, + }, + }, + }; } - - const statusLines: string[] = []; - if (!configured) { - statusLines.push("Feishu: needs app credentials"); - } else if (probeResult?.ok) { - statusLines.push( - `Feishu: connected as ${probeResult.botName ?? probeResult.botOpenId ?? "bot"}`, - ); - } else { - statusLines.push("Feishu: configured (connection not verified)"); - } - - return { - channel, - configured, - statusLines, - selectionHint: configured ? "configured" : "needs app creds", - quickstartScore: configured ? 2 : 0, - }; + return setFeishuNamedAccountEnabled(cfg, accountId, true); }, +}; - configure: async ({ cfg, prompter }) => { +export const feishuSetupWizard: ChannelSetupWizard = { + channel, + resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID, + resolveShouldPromptAccountIds: () => false, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs app credentials", + configuredHint: "configured", + unconfiguredHint: "needs app creds", + configuredScore: 2, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => isFeishuConfigured(cfg), + resolveStatusLines: async ({ cfg, configured }) => { + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + const resolvedCredentials = resolveFeishuCredentials(feishuCfg, { + allowUnresolvedSecretRef: true, + }); + let probeResult = null; + if (configured && resolvedCredentials) { + try { + probeResult = await probeFeishu(resolvedCredentials); + } catch {} + } + if (!configured) { + return ["Feishu: needs app credentials"]; + } + if (probeResult?.ok) { + return [`Feishu: connected as ${probeResult.botName ?? probeResult.botOpenId ?? "bot"}`]; + } + return ["Feishu: configured (connection not verified)"]; + }, + }, + credentials: [], + finalize: async ({ cfg, prompter, options }) => { const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; const resolved = resolveFeishuCredentials(feishuCfg, { allowUnresolvedSecretRef: true, @@ -252,6 +294,7 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { prompter, providerHint: "feishu", credentialLabel: "App Secret", + secretInputMode: options?.secretInputMode, accountConfigured: appSecretPromptState.accountConfigured, canUseEnv: appSecretPromptState.canUseEnv, hasConfigToken: appSecretPromptState.hasConfigToken, @@ -293,7 +336,6 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { }, }; - // Test connection try { const probe = await probeFeishu({ appId, @@ -340,19 +382,17 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { if (connectionMode === "webhook") { const currentVerificationToken = (next.channels?.feishu as FeishuConfig | undefined) ?.verificationToken; - const verificationTokenPromptState = buildSingleChannelSecretPromptState({ - accountConfigured: hasConfiguredSecretInput(currentVerificationToken), - hasConfigToken: hasConfiguredSecretInput(currentVerificationToken), - allowEnv: false, - }); const verificationTokenResult = await promptSingleChannelSecretInput({ cfg: next, prompter, providerHint: "feishu-webhook", credentialLabel: "verification token", - accountConfigured: verificationTokenPromptState.accountConfigured, - canUseEnv: verificationTokenPromptState.canUseEnv, - hasConfigToken: verificationTokenPromptState.hasConfigToken, + secretInputMode: options?.secretInputMode, + ...buildSingleChannelSecretPromptState({ + accountConfigured: hasConfiguredSecretInput(currentVerificationToken), + hasConfigToken: hasConfiguredSecretInput(currentVerificationToken), + allowEnv: false, + }), envPrompt: "", keepPrompt: "Feishu verification token already configured. Keep it?", inputPrompt: "Enter Feishu verification token", @@ -370,20 +410,19 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { }, }; } + const currentEncryptKey = (next.channels?.feishu as FeishuConfig | undefined)?.encryptKey; - const encryptKeyPromptState = buildSingleChannelSecretPromptState({ - accountConfigured: hasConfiguredSecretInput(currentEncryptKey), - hasConfigToken: hasConfiguredSecretInput(currentEncryptKey), - allowEnv: false, - }); const encryptKeyResult = await promptSingleChannelSecretInput({ cfg: next, prompter, providerHint: "feishu-webhook", credentialLabel: "encrypt key", - accountConfigured: encryptKeyPromptState.accountConfigured, - canUseEnv: encryptKeyPromptState.canUseEnv, - hasConfigToken: encryptKeyPromptState.hasConfigToken, + secretInputMode: options?.secretInputMode, + ...buildSingleChannelSecretPromptState({ + accountConfigured: hasConfiguredSecretInput(currentEncryptKey), + hasConfigToken: hasConfiguredSecretInput(currentEncryptKey), + allowEnv: false, + }), envPrompt: "", keepPrompt: "Feishu encrypt key already configured. Keep it?", inputPrompt: "Enter Feishu encrypt key", @@ -401,6 +440,7 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { }, }; } + const currentWebhookPath = (next.channels?.feishu as FeishuConfig | undefined)?.webhookPath; const webhookPath = String( await prompter.text({ @@ -421,7 +461,6 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { }; } - // Domain selection const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu"; const domain = await prompter.select({ message: "Which Feishu domain?", @@ -431,21 +470,18 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { ], initialValue: currentDomain, }); - if (domain) { - next = { - ...next, - channels: { - ...next.channels, - feishu: { - ...next.channels?.feishu, - domain: domain as "feishu" | "lark", - }, + next = { + ...next, + channels: { + ...next.channels, + feishu: { + ...next.channels?.feishu, + domain: domain as "feishu" | "lark", }, - }; - } + }, + }; - // Group policy - const groupPolicy = await prompter.select({ + const groupPolicy = (await prompter.select({ message: "Group chat policy", options: [ { value: "allowlist", label: "Allowlist - only respond in specific groups" }, @@ -453,12 +489,9 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { { value: "disabled", label: "Disabled - don't respond in groups" }, ], initialValue: (next.channels?.feishu as FeishuConfig | undefined)?.groupPolicy ?? "allowlist", - }); - if (groupPolicy) { - next = setFeishuGroupPolicy(next, groupPolicy as "open" | "allowlist" | "disabled"); - } + })) as "allowlist" | "open" | "disabled"; + next = setFeishuGroupPolicy(next, groupPolicy); - // Group allowlist if needed if (groupPolicy === "allowlist") { const existing = (next.channels?.feishu as FeishuConfig | undefined)?.groupAllowFrom ?? []; const entry = await prompter.text({ @@ -474,11 +507,9 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { } } - return { cfg: next, accountId: DEFAULT_ACCOUNT_ID }; + return { cfg: next }; }, - - dmPolicy, - + dmPolicy: feishuDmPolicy, disable: (cfg) => ({ ...cfg, channels: { diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index b374ecfbd63..adba1f8bd93 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -13,8 +13,6 @@ import type { OpenClawConfig, } from "openclaw/plugin-sdk/zalo"; import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, buildBaseAccountStatusSnapshot, buildChannelConfigSchema, buildTokenChannelStatusSummary, @@ -23,9 +21,7 @@ import { deleteAccountFromConfigSection, chunkTextForOutbound, formatAllowFromLowercase, - migrateBaseNameToDefaultAccount, listDirectoryUserEntriesFromAllowFrom, - normalizeAccountId, isNumericTargetId, PAIRING_APPROVED_MESSAGE, resolveOutboundMediaUrls, @@ -40,11 +36,11 @@ import { } from "./accounts.js"; import { zaloMessageActions } from "./actions.js"; import { ZaloConfigSchema } from "./config-schema.js"; -import { zaloOnboardingAdapter } from "./onboarding.js"; import { probeZalo } from "./probe.js"; import { resolveZaloProxyFetch } from "./proxy.js"; import { normalizeSecretInputString } from "./secret-input.js"; import { sendMessageZalo } from "./send.js"; +import { zaloSetupAdapter, zaloSetupWizard } from "./setup-surface.js"; import { collectZaloStatusIssues } from "./status-issues.js"; const meta = { @@ -92,7 +88,8 @@ export const zaloDock: ChannelDock = { export const zaloPlugin: ChannelPlugin = { id: "zalo", meta, - onboarding: zaloOnboardingAdapter, + setup: zaloSetupAdapter, + setupWizard: zaloSetupWizard, capabilities: { chatTypes: ["direct", "group"], media: true, @@ -212,53 +209,6 @@ export const zaloPlugin: ChannelPlugin = { }, listGroups: async () => [], }, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "zalo", - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "ZALO_BOT_TOKEN can only be used for the default account."; - } - if (!input.useEnv && !input.token && !input.tokenFile) { - return "Zalo requires token or --token-file (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "zalo", - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "zalo", - }) - : namedConfig; - const patch = input.useEnv - ? {} - : input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { botToken: input.token } - : {}; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: "zalo", - accountId, - patch, - }); - }, - }, pairing: { idLabel: "zaloUserId", normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""), diff --git a/extensions/zalo/src/onboarding.status.test.ts b/extensions/zalo/src/onboarding.status.test.ts index fed5ea95f89..4db31735c94 100644 --- a/extensions/zalo/src/onboarding.status.test.ts +++ b/extensions/zalo/src/onboarding.status.test.ts @@ -1,10 +1,16 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo"; import { describe, expect, it } from "vitest"; -import { zaloOnboardingAdapter } from "./onboarding.js"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { zaloPlugin } from "./channel.js"; -describe("zalo onboarding status", () => { +const zaloConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: zaloPlugin, + wizard: zaloPlugin.setupWizard!, +}); + +describe("zalo setup wizard status", () => { it("treats SecretRef botToken as configured", async () => { - const status = await zaloOnboardingAdapter.getStatus({ + const status = await zaloConfigureAdapter.getStatus({ cfg: { channels: { zalo: { diff --git a/extensions/zalo/src/setup-surface.test.ts b/extensions/zalo/src/setup-surface.test.ts new file mode 100644 index 00000000000..2353a66e453 --- /dev/null +++ b/extensions/zalo/src/setup-surface.test.ts @@ -0,0 +1,60 @@ +import type { OpenClawConfig, RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/zalo"; +import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { zaloPlugin } from "./channel.js"; + +function createPrompter(overrides: Partial): WizardPrompter { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: vi.fn(async () => "plaintext") as WizardPrompter["select"], + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} + +const zaloConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: zaloPlugin, + wizard: zaloPlugin.setupWizard!, +}); + +describe("zalo setup wizard", () => { + it("configures a polling token flow", async () => { + const prompter = createPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Enter Zalo bot token") { + return "12345689:abc-xyz"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Use webhook mode for Zalo?") { + return false; + } + return false; + }), + }); + + const runtime: RuntimeEnv = createRuntimeEnv(); + + const result = await zaloConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime, + prompter, + options: { secretInputMode: "plaintext" }, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.zalo?.enabled).toBe(true); + expect(result.cfg.channels?.zalo?.botToken).toBe("12345689:abc-xyz"); + expect(result.cfg.channels?.zalo?.webhookUrl).toBeUndefined(); + }); +}); diff --git a/extensions/zalo/src/onboarding.ts b/extensions/zalo/src/setup-surface.ts similarity index 65% rename from extensions/zalo/src/onboarding.ts rename to extensions/zalo/src/setup-surface.ts index 4c6f7cbe4de..643c2f6ff76 100644 --- a/extensions/zalo/src/onboarding.ts +++ b/extensions/zalo/src/setup-surface.ts @@ -1,21 +1,23 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, - OpenClawConfig, - SecretInput, - WizardPrompter, -} from "openclaw/plugin-sdk/zalo"; +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { buildSingleChannelSecretPromptState, - DEFAULT_ACCOUNT_ID, - hasConfiguredSecretInput, mergeAllowFromEntries, - normalizeAccountId, promptSingleChannelSecretInput, runSingleChannelSecretStep, - resolveAccountIdForConfigure, setTopLevelChannelDmPolicyWithAllowFrom, -} from "openclaw/plugin-sdk/zalo"; +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { SecretInput } from "../../../src/config/types.secrets.js"; +import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js"; const channel = "zalo" as const; @@ -28,7 +30,7 @@ function setZaloDmPolicy( ) { return setTopLevelChannelDmPolicyWithAllowFrom({ cfg, - channel: "zalo", + channel, dmPolicy, }) as OpenClawConfig; } @@ -108,14 +110,16 @@ function setZaloUpdateMode( } as OpenClawConfig; } -async function noteZaloTokenHelp(prompter: WizardPrompter): Promise { +async function noteZaloTokenHelp( + prompter: Parameters>[0]["prompter"], +): Promise { await prompter.note( [ "1) Open Zalo Bot Platform: https://bot.zaloplatforms.com", "2) Create a bot and get the token", "3) Token looks like 12345689:abc-xyz", "Tip: you can also set ZALO_BOT_TOKEN in your env.", - "Docs: https://docs.openclaw.ai/channels/zalo", + `Docs: ${formatDocsLink("/channels/zalo", "zalo")}`, ].join("\n"), "Zalo bot token", ); @@ -123,7 +127,7 @@ async function noteZaloTokenHelp(prompter: WizardPrompter): Promise { async function promptZaloAllowFrom(params: { cfg: OpenClawConfig; - prompter: WizardPrompter; + prompter: Parameters>[0]["prompter"]; accountId: string; }): Promise { const { cfg, prompter, accountId } = params; @@ -183,76 +187,111 @@ async function promptZaloAllowFrom(params: { } as OpenClawConfig; } -const dmPolicy: ChannelOnboardingDmPolicy = { +const zaloDmPolicy: ChannelOnboardingDmPolicy = { label: "Zalo", channel, policyKey: "channels.zalo.dmPolicy", allowFromKey: "channels.zalo.allowFrom", getCurrent: (cfg) => (cfg.channels?.zalo?.dmPolicy ?? "pairing") as "pairing", - setPolicy: (cfg, policy) => setZaloDmPolicy(cfg, policy), + setPolicy: (cfg, policy) => setZaloDmPolicy(cfg as OpenClawConfig, policy), promptAllowFrom: async ({ cfg, prompter, accountId }) => { const id = accountId && normalizeAccountId(accountId) ? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID) - : resolveDefaultZaloAccountId(cfg); - return promptZaloAllowFrom({ - cfg: cfg, + : resolveDefaultZaloAccountId(cfg as OpenClawConfig); + return await promptZaloAllowFrom({ + cfg: cfg as OpenClawConfig, prompter, accountId: id, }); }, }; -export const zaloOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - dmPolicy, - getStatus: async ({ cfg }) => { - const configured = listZaloAccountIds(cfg).some((accountId) => { - const account = resolveZaloAccount({ - cfg: cfg, - accountId, - allowUnresolvedSecretRef: true, - }); - return ( - Boolean(account.token) || - hasConfiguredSecretInput(account.config.botToken) || - Boolean(account.config.tokenFile?.trim()) - ); - }); - return { - channel, - configured, - statusLines: [`Zalo: ${configured ? "configured" : "needs token"}`], - selectionHint: configured ? "recommended · configured" : "recommended · newcomer-friendly", - quickstartScore: configured ? 1 : 10, - }; - }, - configure: async ({ - cfg, - prompter, - accountOverrides, - shouldPromptAccountIds, - forceAllowFrom, - }) => { - const defaultZaloAccountId = resolveDefaultZaloAccountId(cfg); - const zaloAccountId = await resolveAccountIdForConfigure({ +export const zaloSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ cfg, - prompter, - label: "Zalo", - accountOverride: accountOverrides.zalo, - shouldPromptAccountIds, - listAccountIds: listZaloAccountIds, - defaultAccountId: defaultZaloAccountId, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "ZALO_BOT_TOKEN can only be used for the default account."; + } + if (!input.useEnv && !input.token && !input.tokenFile) { + return "Zalo requires token or --token-file (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + const patch = input.useEnv + ? {} + : input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}; + return applySetupAccountConfigPatch({ + cfg: next, + channelKey: channel, + accountId, + patch, + }); + }, +}; +export const zaloSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs token", + configuredHint: "recommended · configured", + unconfiguredHint: "recommended · newcomer-friendly", + configuredScore: 1, + unconfiguredScore: 10, + resolveConfigured: ({ cfg }) => + listZaloAccountIds(cfg).some((accountId) => { + const account = resolveZaloAccount({ + cfg, + accountId, + allowUnresolvedSecretRef: true, + }); + return ( + Boolean(account.token) || + hasConfiguredSecretInput(account.config.botToken) || + Boolean(account.config.tokenFile?.trim()) + ); + }), + resolveStatusLines: ({ cfg, configured }) => { + void cfg; + return [`Zalo: ${configured ? "configured" : "needs token"}`]; + }, + }, + credentials: [], + finalize: async ({ cfg, accountId, forceAllowFrom, options, prompter }) => { let next = cfg; const resolvedAccount = resolveZaloAccount({ cfg: next, - accountId: zaloAccountId, + accountId, allowUnresolvedSecretRef: true, }); const accountConfigured = Boolean(resolvedAccount.token); - const allowEnv = zaloAccountId === DEFAULT_ACCOUNT_ID; + const allowEnv = accountId === DEFAULT_ACCOUNT_ID; const hasConfigToken = Boolean( hasConfiguredSecretInput(resolvedAccount.config.botToken) || resolvedAccount.config.tokenFile, ); @@ -261,6 +300,7 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = { prompter, providerHint: "zalo", credentialLabel: "bot token", + secretInputMode: options?.secretInputMode, accountConfigured, hasConfigToken, allowEnv, @@ -270,43 +310,43 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = { inputPrompt: "Enter Zalo bot token", preferredEnvVar: "ZALO_BOT_TOKEN", onMissingConfigured: async () => await noteZaloTokenHelp(prompter), - applyUseEnv: async (cfg) => - zaloAccountId === DEFAULT_ACCOUNT_ID + applyUseEnv: async (currentCfg) => + accountId === DEFAULT_ACCOUNT_ID ? ({ - ...cfg, + ...currentCfg, channels: { - ...cfg.channels, + ...currentCfg.channels, zalo: { - ...cfg.channels?.zalo, + ...currentCfg.channels?.zalo, enabled: true, }, }, } as OpenClawConfig) - : cfg, - applySet: async (cfg, value) => - zaloAccountId === DEFAULT_ACCOUNT_ID + : currentCfg, + applySet: async (currentCfg, value) => + accountId === DEFAULT_ACCOUNT_ID ? ({ - ...cfg, + ...currentCfg, channels: { - ...cfg.channels, + ...currentCfg.channels, zalo: { - ...cfg.channels?.zalo, + ...currentCfg.channels?.zalo, enabled: true, botToken: value, }, }, } as OpenClawConfig) : ({ - ...cfg, + ...currentCfg, channels: { - ...cfg.channels, + ...currentCfg.channels, zalo: { - ...cfg.channels?.zalo, + ...currentCfg.channels?.zalo, enabled: true, accounts: { - ...cfg.channels?.zalo?.accounts, - [zaloAccountId]: { - ...cfg.channels?.zalo?.accounts?.[zaloAccountId], + ...currentCfg.channels?.zalo?.accounts, + [accountId]: { + ...currentCfg.channels?.zalo?.accounts?.[accountId], enabled: true, botToken: value, }, @@ -337,11 +377,13 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = { return "/zalo-webhook"; } })(); + let webhookSecretResult = await promptSingleChannelSecretInput({ cfg: next, prompter, providerHint: "zalo-webhook", credentialLabel: "webhook secret", + secretInputMode: options?.secretInputMode, ...buildSingleChannelSecretPromptState({ accountConfigured: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret), hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret), @@ -363,6 +405,7 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = { prompter, providerHint: "zalo-webhook", credentialLabel: "webhook secret", + secretInputMode: options?.secretInputMode, ...buildSingleChannelSecretPromptState({ accountConfigured: false, hasConfigToken: false, @@ -386,24 +429,25 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = { ).trim(); next = setZaloUpdateMode( next, - zaloAccountId, + accountId, "webhook", webhookUrl, webhookSecret, webhookPath || undefined, ); } else { - next = setZaloUpdateMode(next, zaloAccountId, "polling"); + next = setZaloUpdateMode(next, accountId, "polling"); } if (forceAllowFrom) { next = await promptZaloAllowFrom({ cfg: next, prompter, - accountId: zaloAccountId, + accountId, }); } - return { cfg: next, accountId: zaloAccountId }; + return { cfg: next }; }, + dmPolicy: zaloDmPolicy, }; diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 81fce5e3ab9..b7d103e9b6e 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -14,8 +14,6 @@ import type { GroupToolPolicyConfig, } from "openclaw/plugin-sdk/zalouser"; import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, buildChannelSendResult, buildBaseAccountStatusSnapshot, buildChannelConfigSchema, @@ -24,7 +22,6 @@ import { formatAllowFromLowercase, isDangerousNameMatchingEnabled, isNumericTargetId, - migrateBaseNameToDefaultAccount, normalizeAccountId, sendPayloadWithChunkedTextAndMedia, setAccountEnabledInConfigSection, @@ -41,11 +38,11 @@ import { import { ZalouserConfigSchema } from "./config-schema.js"; import { buildZalouserGroupCandidates, findZalouserGroupEntry } from "./group-policy.js"; import { resolveZalouserReactionMessageIds } from "./message-sid.js"; -import { zalouserOnboardingAdapter } from "./onboarding.js"; import { probeZalouser } from "./probe.js"; import { writeQrDataUrlToTempFile } from "./qr-temp-file.js"; import { getZalouserRuntime } from "./runtime.js"; import { sendMessageZalouser, sendReactionZalouser } from "./send.js"; +import { zalouserSetupAdapter, zalouserSetupWizard } from "./setup-surface.js"; import { collectZalouserStatusIssues } from "./status-issues.js"; import { listZaloFriendsMatching, @@ -332,7 +329,8 @@ export const zalouserDock: ChannelDock = { export const zalouserPlugin: ChannelPlugin = { id: "zalouser", meta, - onboarding: zalouserOnboardingAdapter, + setup: zalouserSetupAdapter, + setupWizard: zalouserSetupWizard, capabilities: { chatTypes: ["direct", "group"], media: true, @@ -407,38 +405,6 @@ export const zalouserPlugin: ChannelPlugin = { resolveReplyToMode: () => "off", }, actions: zalouserMessageActions, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "zalouser", - accountId, - name, - }), - validateInput: () => null, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "zalouser", - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "zalouser", - }) - : namedConfig; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: "zalouser", - accountId, - patch: {}, - }); - }, - }, messaging: { normalizeTarget: (raw) => normalizePrefixedTarget(raw), targetResolver: { diff --git a/extensions/zalouser/src/setup-surface.test.ts b/extensions/zalouser/src/setup-surface.test.ts new file mode 100644 index 00000000000..d28fd8f0ccc --- /dev/null +++ b/extensions/zalouser/src/setup-surface.test.ts @@ -0,0 +1,86 @@ +import type { OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk/zalouser"; +import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; + +vi.mock("./zalo-js.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + checkZaloAuthenticated: vi.fn(async () => false), + logoutZaloProfile: vi.fn(async () => {}), + startZaloQrLogin: vi.fn(async () => ({ + message: "qr pending", + qrDataUrl: undefined, + })), + waitForZaloQrLogin: vi.fn(async () => ({ + connected: false, + message: "login pending", + })), + resolveZaloAllowFromEntries: vi.fn(async ({ entries }: { entries: string[] }) => + entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })), + ), + resolveZaloGroupsByEntries: vi.fn(async ({ entries }: { entries: string[] }) => + entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })), + ), + }; +}); + +import { zalouserPlugin } from "./channel.js"; + +const selectFirstOption = async (params: { options: Array<{ value: T }> }): Promise => { + const first = params.options[0]; + if (!first) { + throw new Error("no options"); + } + return first.value; +}; + +function createPrompter(overrides: Partial): WizardPrompter { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: selectFirstOption as WizardPrompter["select"], + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} + +const zalouserConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: zalouserPlugin, + wizard: zalouserPlugin.setupWizard!, +}); + +describe("zalouser setup wizard", () => { + it("enables the account without forcing QR login", async () => { + const runtime = createRuntimeEnv(); + const prompter = createPrompter({ + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Login via QR code now?") { + return false; + } + if (message === "Configure Zalo groups access?") { + return false; + } + return false; + }), + }); + + const result = await zalouserConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime, + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.zalouser?.enabled).toBe(true); + }); +}); diff --git a/extensions/zalouser/src/onboarding.ts b/extensions/zalouser/src/setup-surface.ts similarity index 57% rename from extensions/zalouser/src/onboarding.ts rename to extensions/zalouser/src/setup-surface.ts index d5b828b6711..b091ed37947 100644 --- a/extensions/zalouser/src/onboarding.ts +++ b/extensions/zalouser/src/setup-surface.ts @@ -1,19 +1,20 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, - OpenClawConfig, - WizardPrompter, -} from "openclaw/plugin-sdk/zalouser"; +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { - DEFAULT_ACCOUNT_ID, - formatResolvedUnresolvedNote, mergeAllowFromEntries, - normalizeAccountId, - patchScopedAccountConfig, - promptChannelAccessConfig, - resolveAccountIdForConfigure, setTopLevelChannelDmPolicyWithAllowFrom, -} from "openclaw/plugin-sdk/zalouser"; +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + migrateBaseNameToDefaultAccount, + patchScopedAccountConfig, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { formatResolvedUnresolvedNote } from "../../../src/plugin-sdk/resolution-notes.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { listZalouserAccountIds, resolveDefaultZalouserAccountId, @@ -52,19 +53,42 @@ function setZalouserDmPolicy( ): OpenClawConfig { return setTopLevelChannelDmPolicyWithAllowFrom({ cfg, - channel: "zalouser", + channel, dmPolicy, }) as OpenClawConfig; } -async function noteZalouserHelp(prompter: WizardPrompter): Promise { +function setZalouserGroupPolicy( + cfg: OpenClawConfig, + accountId: string, + groupPolicy: "open" | "allowlist" | "disabled", +): OpenClawConfig { + return setZalouserAccountScopedConfig(cfg, accountId, { + groupPolicy, + }); +} + +function setZalouserGroupAllowlist( + cfg: OpenClawConfig, + accountId: string, + groupKeys: string[], +): OpenClawConfig { + const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }])); + return setZalouserAccountScopedConfig(cfg, accountId, { + groups, + }); +} + +async function noteZalouserHelp( + prompter: Parameters>[0]["prompter"], +): Promise { await prompter.note( [ "Zalo Personal Account login via QR code.", "", "This plugin uses zca-js directly (no external CLI dependency).", "", - "Docs: https://docs.openclaw.ai/channels/zalouser", + `Docs: ${formatDocsLink("/channels/zalouser", "zalouser")}`, ].join("\n"), "Zalo Personal Setup", ); @@ -72,7 +96,7 @@ async function noteZalouserHelp(prompter: WizardPrompter): Promise { async function promptZalouserAllowFrom(params: { cfg: OpenClawConfig; - prompter: WizardPrompter; + prompter: Parameters>[0]["prompter"]; accountId: string; }): Promise { const { cfg, prompter, accountId } = params; @@ -125,94 +149,90 @@ async function promptZalouserAllowFrom(params: { } } -function setZalouserGroupPolicy( - cfg: OpenClawConfig, - accountId: string, - groupPolicy: "open" | "allowlist" | "disabled", -): OpenClawConfig { - return setZalouserAccountScopedConfig(cfg, accountId, { - groupPolicy, - }); -} - -function setZalouserGroupAllowlist( - cfg: OpenClawConfig, - accountId: string, - groupKeys: string[], -): OpenClawConfig { - const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }])); - return setZalouserAccountScopedConfig(cfg, accountId, { - groups, - }); -} - -const dmPolicy: ChannelOnboardingDmPolicy = { +const zalouserDmPolicy: ChannelOnboardingDmPolicy = { label: "Zalo Personal", channel, policyKey: "channels.zalouser.dmPolicy", allowFromKey: "channels.zalouser.allowFrom", getCurrent: (cfg) => (cfg.channels?.zalouser?.dmPolicy ?? "pairing") as "pairing", - setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg, policy), + setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as OpenClawConfig, policy), promptAllowFrom: async ({ cfg, prompter, accountId }) => { const id = accountId && normalizeAccountId(accountId) ? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID) - : resolveDefaultZalouserAccountId(cfg); - return promptZalouserAllowFrom({ - cfg, + : resolveDefaultZalouserAccountId(cfg as OpenClawConfig); + return await promptZalouserAllowFrom({ + cfg: cfg as OpenClawConfig, prompter, accountId: id, }); }, }; -export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - dmPolicy, - getStatus: async ({ cfg }) => { - const ids = listZalouserAccountIds(cfg); - let configured = false; - for (const accountId of ids) { - const account = resolveZalouserAccountSync({ cfg, accountId }); - const isAuth = await checkZcaAuthenticated(account.profile); - if (isAuth) { - configured = true; - break; - } - } - return { - channel, - configured, - statusLines: [`Zalo Personal: ${configured ? "logged in" : "needs QR login"}`], - selectionHint: configured ? "recommended · logged in" : "recommended · QR login", - quickstartScore: configured ? 1 : 15, - }; - }, - configure: async ({ - cfg, - prompter, - accountOverrides, - shouldPromptAccountIds, - forceAllowFrom, - }) => { - const defaultAccountId = resolveDefaultZalouserAccountId(cfg); - const accountId = await resolveAccountIdForConfigure({ +export const zalouserSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ cfg, - prompter, - label: "Zalo Personal", - accountOverride: accountOverrides.zalouser, - shouldPromptAccountIds, - listAccountIds: listZalouserAccountIds, - defaultAccountId, + channelKey: channel, + accountId, + name, + }), + validateInput: () => null, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + return applySetupAccountConfigPatch({ + cfg: next, + channelKey: channel, + accountId, + patch: {}, + }); + }, +}; +export const zalouserSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "logged in", + unconfiguredLabel: "needs QR login", + configuredHint: "recommended · logged in", + unconfiguredHint: "recommended · QR login", + configuredScore: 1, + unconfiguredScore: 15, + resolveConfigured: async ({ cfg }) => { + const ids = listZalouserAccountIds(cfg); + for (const accountId of ids) { + const account = resolveZalouserAccountSync({ cfg, accountId }); + if (await checkZcaAuthenticated(account.profile)) { + return true; + } + } + return false; + }, + resolveStatusLines: async ({ cfg, configured }) => { + void cfg; + return [`Zalo Personal: ${configured ? "logged in" : "needs QR login"}`]; + }, + }, + prepare: async ({ cfg, accountId, prompter }) => { let next = cfg; const account = resolveZalouserAccountSync({ cfg: next, accountId }); const alreadyAuthenticated = await checkZcaAuthenticated(account.profile); if (!alreadyAuthenticated) { await noteZalouserHelp(prompter); - const wantsLogin = await prompter.confirm({ message: "Login via QR code now?", initialValue: true, @@ -280,6 +300,56 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { { profile: account.profile, enabled: true }, ); + return { cfg: next }; + }, + credentials: [], + groupAccess: { + label: "Zalo groups", + placeholder: "Family, Work, 123456789", + currentPolicy: ({ cfg, accountId }) => + resolveZalouserAccountSync({ cfg, accountId }).config.groupPolicy ?? "allowlist", + currentEntries: ({ cfg, accountId }) => + Object.keys(resolveZalouserAccountSync({ cfg, accountId }).config.groups ?? {}), + updatePrompt: ({ cfg, accountId }) => + Boolean(resolveZalouserAccountSync({ cfg, accountId }).config.groups), + setPolicy: ({ cfg, accountId, policy }) => + setZalouserGroupPolicy(cfg as OpenClawConfig, accountId, policy), + resolveAllowlist: async ({ cfg, accountId, entries, prompter }) => { + if (entries.length === 0) { + return []; + } + const updatedAccount = resolveZalouserAccountSync({ cfg: cfg as OpenClawConfig, accountId }); + try { + const resolved = await resolveZaloGroupsByEntries({ + profile: updatedAccount.profile, + entries, + }); + const resolvedIds = resolved + .filter((entry) => entry.resolved && entry.id) + .map((entry) => entry.id as string); + const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input); + const keys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; + const resolution = formatResolvedUnresolvedNote({ + resolved: resolvedIds, + unresolved, + }); + if (resolution) { + await prompter.note(resolution, "Zalo groups"); + } + return keys; + } catch (err) { + await prompter.note( + `Group lookup failed; keeping entries as typed. ${String(err)}`, + "Zalo groups", + ); + return entries.map((entry) => entry.trim()).filter(Boolean); + } + }, + applyAllowlist: ({ cfg, accountId, resolved }) => + setZalouserGroupAllowlist(cfg as OpenClawConfig, accountId, resolved as string[]), + }, + finalize: async ({ cfg, accountId, forceAllowFrom, prompter }) => { + let next = cfg; if (forceAllowFrom) { next = await promptZalouserAllowFrom({ cfg: next, @@ -287,54 +357,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { accountId, }); } - - const updatedAccount = resolveZalouserAccountSync({ cfg: next, accountId }); - const accessConfig = await promptChannelAccessConfig({ - prompter, - label: "Zalo groups", - currentPolicy: updatedAccount.config.groupPolicy ?? "allowlist", - currentEntries: Object.keys(updatedAccount.config.groups ?? {}), - placeholder: "Family, Work, 123456789", - updatePrompt: Boolean(updatedAccount.config.groups), - }); - - if (accessConfig) { - if (accessConfig.policy !== "allowlist") { - next = setZalouserGroupPolicy(next, accountId, accessConfig.policy); - } else { - let keys = accessConfig.entries; - if (accessConfig.entries.length > 0) { - try { - const resolved = await resolveZaloGroupsByEntries({ - profile: updatedAccount.profile, - entries: accessConfig.entries, - }); - const resolvedIds = resolved - .filter((entry) => entry.resolved && entry.id) - .map((entry) => entry.id as string); - const unresolved = resolved - .filter((entry) => !entry.resolved) - .map((entry) => entry.input); - keys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; - const resolution = formatResolvedUnresolvedNote({ - resolved: resolvedIds, - unresolved, - }); - if (resolution) { - await prompter.note(resolution, "Zalo groups"); - } - } catch (err) { - await prompter.note( - `Group lookup failed; keeping entries as typed. ${String(err)}`, - "Zalo groups", - ); - } - } - next = setZalouserGroupPolicy(next, accountId, "allowlist"); - next = setZalouserGroupAllowlist(next, accountId, keys); - } - } - - return { cfg: next, accountId }; + return { cfg: next }; }, + dmPolicy: zalouserDmPolicy, }; diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 772cde76ff2..65f0773105b 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -13,10 +13,6 @@ export { logTypingFailure } from "../channels/logging.js"; export type { AllowlistMatch } from "../channels/plugins/allowlist-match.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { createActionGate } from "../agents/tools/common.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; export { buildSingleChannelSecretPromptState, addWildcardAllowFrom, @@ -66,6 +62,10 @@ export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export { evaluateSenderGroupAccessForPolicy } from "./group-access.js"; export type { WizardPrompter } from "../wizard/prompts.js"; +export { + feishuSetupAdapter, + feishuSetupWizard, +} from "../../extensions/feishu/src/setup-surface.js"; export { buildAgentMediaPayload } from "./agent-media-payload.js"; export { readJsonFileWithFallback } from "./json-store.js"; export { createScopedPairingAccess } from "./pairing-access.js"; diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index e13529f8c42..4323ae4eb6e 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -11,10 +11,6 @@ export { export { listDirectoryUserEntriesFromAllowFrom } from "../channels/plugins/directory-config-helpers.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; export { buildSingleChannelSecretPromptState, addWildcardAllowFrom, @@ -22,7 +18,6 @@ export { promptAccountId, promptSingleChannelSecretInput, runSingleChannelSecretStep, - resolveAccountIdForConfigure, setTopLevelChannelDmPolicyWithAllowFrom, } from "../channels/plugins/onboarding/helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; @@ -69,6 +64,7 @@ export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.j export type { RuntimeEnv } from "../runtime.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { formatAllowFromLowercase, isNormalizedSenderAllowed } from "./allow-from.js"; +export { zaloSetupAdapter, zaloSetupWizard } from "../../extensions/zalo/src/setup-surface.js"; export { resolveDirectDmAuthorizationOutcome, resolveSenderCommandAuthorizationWithRuntime, diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index 4b8ef88d06d..47fc787570c 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -11,16 +11,10 @@ export { } from "../channels/plugins/config-helpers.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; -export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js"; export { addWildcardAllowFrom, mergeAllowFromEntries, promptAccountId, - resolveAccountIdForConfigure, setTopLevelChannelDmPolicyWithAllowFrom, } from "../channels/plugins/onboarding/helpers.js"; export { @@ -61,6 +55,10 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { formatAllowFromLowercase } from "./allow-from.js"; export { resolveSenderCommandAuthorization } from "./command-auth.js"; export { resolveChannelAccountConfigBasePath } from "./config-paths.js"; +export { + zalouserSetupAdapter, + zalouserSetupWizard, +} from "../../extensions/zalouser/src/setup-surface.js"; export { evaluateGroupRouteAccessForPolicy, resolveSenderScopedGroupPolicy, From 0958aea1125bc76a87301fd1bf5132068a980d25 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 18:23:06 -0700 Subject: [PATCH 095/943] refactor: move matrix msteams twitch to setup wizard --- extensions/matrix/src/channel.ts | 101 +-------- .../src/{onboarding.ts => setup-surface.ts} | 202 +++++++++++++----- extensions/msteams/src/channel.ts | 18 +- .../src/{onboarding.ts => setup-surface.ts} | 105 +++++---- extensions/twitch/src/onboarding.test.ts | 39 ++-- extensions/twitch/src/plugin.ts | 7 +- .../src/{onboarding.ts => setup-surface.ts} | 149 ++++++------- src/plugin-sdk/matrix.ts | 9 +- src/plugin-sdk/msteams.ts | 9 +- src/plugin-sdk/twitch.ts | 9 +- 10 files changed, 316 insertions(+), 332 deletions(-) rename extensions/matrix/src/{onboarding.ts => setup-surface.ts} (71%) rename extensions/msteams/src/{onboarding.ts => setup-surface.ts} (80%) rename extensions/twitch/src/{onboarding.ts => setup-surface.ts} (76%) diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index a6a33a7f627..8e3c858ecde 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -6,12 +6,10 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/compat"; import { - applyAccountNameToChannelSection, buildChannelConfigSchema, buildProbeChannelStatusSummary, collectStatusIssuesFromLastError, DEFAULT_ACCOUNT_ID, - normalizeAccountId, PAIRING_APPROVED_MESSAGE, type ChannelPlugin, } from "openclaw/plugin-sdk/matrix"; @@ -30,9 +28,8 @@ import { type ResolvedMatrixAccount, } from "./matrix/accounts.js"; import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js"; -import { matrixOnboardingAdapter } from "./onboarding.js"; import { getMatrixRuntime } from "./runtime.js"; -import { normalizeSecretInputString } from "./secret-input.js"; +import { matrixSetupAdapter, matrixSetupWizard } from "./setup-surface.js"; import type { CoreConfig } from "./types.js"; // Mutex for serializing account startup (workaround for concurrent dynamic import race condition) @@ -66,38 +63,6 @@ function normalizeMatrixMessagingTarget(raw: string): string | undefined { return stripped || undefined; } -function buildMatrixConfigUpdate( - cfg: CoreConfig, - input: { - homeserver?: string; - userId?: string; - accessToken?: string; - password?: string; - deviceName?: string; - initialSyncLimit?: number; - }, -): CoreConfig { - const existing = cfg.channels?.matrix ?? {}; - return { - ...cfg, - channels: { - ...cfg.channels, - matrix: { - ...existing, - enabled: true, - ...(input.homeserver ? { homeserver: input.homeserver } : {}), - ...(input.userId ? { userId: input.userId } : {}), - ...(input.accessToken ? { accessToken: input.accessToken } : {}), - ...(input.password ? { password: input.password } : {}), - ...(input.deviceName ? { deviceName: input.deviceName } : {}), - ...(typeof input.initialSyncLimit === "number" - ? { initialSyncLimit: input.initialSyncLimit } - : {}), - }, - }, - }; -} - const matrixConfigAccessors = createScopedAccountConfigAccessors({ resolveAccount: ({ cfg, accountId }) => resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }), @@ -132,7 +97,7 @@ const resolveMatrixDmPolicy = createScopedDmSecurityResolver = { id: "matrix", meta, - onboarding: matrixOnboardingAdapter, + setupWizard: matrixSetupWizard, pairing: { idLabel: "matrixUserId", normalizeAllowEntry: (entry) => entry.replace(/^matrix:/i, ""), @@ -316,67 +281,7 @@ export const matrixPlugin: ChannelPlugin = { (await loadMatrixChannelRuntime()).resolveMatrixTargets({ cfg, inputs, kind, runtime }), }, actions: matrixMessageActions, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg: cfg as CoreConfig, - channelKey: "matrix", - accountId, - name, - }), - validateInput: ({ input }) => { - if (input.useEnv) { - return null; - } - if (!input.homeserver?.trim()) { - return "Matrix requires --homeserver"; - } - const accessToken = input.accessToken?.trim(); - const password = normalizeSecretInputString(input.password); - const userId = input.userId?.trim(); - if (!accessToken && !password) { - return "Matrix requires --access-token or --password"; - } - if (!accessToken) { - if (!userId) { - return "Matrix requires --user-id when using --password"; - } - if (!password) { - return "Matrix requires --password when using --user-id"; - } - } - return null; - }, - applyAccountConfig: ({ cfg, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg: cfg as CoreConfig, - channelKey: "matrix", - accountId: DEFAULT_ACCOUNT_ID, - name: input.name, - }); - if (input.useEnv) { - return { - ...namedConfig, - channels: { - ...namedConfig.channels, - matrix: { - ...namedConfig.channels?.matrix, - enabled: true, - }, - }, - } as CoreConfig; - } - return buildMatrixConfigUpdate(namedConfig as CoreConfig, { - homeserver: input.homeserver?.trim(), - userId: input.userId?.trim(), - accessToken: input.accessToken?.trim(), - password: normalizeSecretInputString(input.password), - deviceName: input.deviceName?.trim(), - initialSyncLimit: input.initialSyncLimit, - }); - }, - }, + setup: matrixSetupAdapter, outbound: { deliveryMode: "direct", chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText!(text, limit), diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/setup-surface.ts similarity index 71% rename from extensions/matrix/src/onboarding.ts rename to extensions/matrix/src/setup-surface.ts index 642522dbc50..9f37f000c46 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/setup-surface.ts @@ -1,19 +1,29 @@ -import type { DmPolicy } from "openclaw/plugin-sdk/matrix"; +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js"; import { addWildcardAllowFrom, buildSingleChannelSecretPromptState, - formatResolvedUnresolvedNote, - formatDocsLink, - hasConfiguredSecretInput, mergeAllowFromEntries, promptSingleChannelSecretInput, - promptChannelAccessConfig, setTopLevelChannelGroupPolicy, - type SecretInput, - type ChannelOnboardingAdapter, - type ChannelOnboardingDmPolicy, - type WizardPrompter, -} from "openclaw/plugin-sdk/matrix"; +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DmPolicy } from "../../../src/config/types.js"; +import type { SecretInput } from "../../../src/config/types.secrets.js"; +import { + hasConfiguredSecretInput, + normalizeSecretInputString, +} from "../../../src/config/types.secrets.js"; +import { formatResolvedUnresolvedNote } from "../../../src/plugin-sdk/resolution-notes.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; import { resolveMatrixAccount } from "./matrix/accounts.js"; import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js"; @@ -22,6 +32,38 @@ import type { CoreConfig } from "./types.js"; const channel = "matrix" as const; +function buildMatrixConfigUpdate( + cfg: CoreConfig, + input: { + homeserver?: string; + userId?: string; + accessToken?: string; + password?: string; + deviceName?: string; + initialSyncLimit?: number; + }, +): CoreConfig { + const existing = cfg.channels?.matrix ?? {}; + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...existing, + enabled: true, + ...(input.homeserver ? { homeserver: input.homeserver } : {}), + ...(input.userId ? { userId: input.userId } : {}), + ...(input.accessToken ? { accessToken: input.accessToken } : {}), + ...(input.password ? { password: input.password } : {}), + ...(input.deviceName ? { deviceName: input.deviceName } : {}), + ...(typeof input.initialSyncLimit === "number" + ? { initialSyncLimit: input.initialSyncLimit } + : {}), + }, + }, + }; +} + function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) { const allowFrom = policy === "open" ? addWildcardAllowFrom(cfg.channels?.matrix?.dm?.allowFrom) : undefined; @@ -168,7 +210,7 @@ function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) { }; } -const dmPolicy: ChannelOnboardingDmPolicy = { +const matrixDmPolicy: ChannelOnboardingDmPolicy = { label: "Matrix", channel, policyKey: "channels.matrix.dm.policy", @@ -178,26 +220,100 @@ const dmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptMatrixAllowFrom, }; -export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const account = resolveMatrixAccount({ cfg: cfg as CoreConfig }); - const configured = account.configured; - const sdkReady = isMatrixSdkAvailable(); - return { - channel, - configured, - statusLines: [ - `Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`, - ], - selectionHint: !sdkReady - ? "install @vector-im/matrix-bot-sdk" - : configured - ? "configured" - : "needs auth", - }; +export const matrixSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg: cfg as CoreConfig, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ input }) => { + if (input.useEnv) { + return null; + } + if (!input.homeserver?.trim()) { + return "Matrix requires --homeserver"; + } + const accessToken = input.accessToken?.trim(); + const password = normalizeSecretInputString(input.password); + const userId = input.userId?.trim(); + if (!accessToken && !password) { + return "Matrix requires --access-token or --password"; + } + if (!accessToken) { + if (!userId) { + return "Matrix requires --user-id when using --password"; + } + if (!password) { + return "Matrix requires --password when using --user-id"; + } + } + return null; }, - configure: async ({ cfg, runtime, prompter, forceAllowFrom }) => { + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg: cfg as CoreConfig, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (input.useEnv) { + return { + ...next, + channels: { + ...next.channels, + matrix: { + ...next.channels?.matrix, + enabled: true, + }, + }, + } as CoreConfig; + } + return buildMatrixConfigUpdate(next as CoreConfig, { + homeserver: input.homeserver?.trim(), + userId: input.userId?.trim(), + accessToken: input.accessToken?.trim(), + password: normalizeSecretInputString(input.password), + deviceName: input.deviceName?.trim(), + initialSyncLimit: input.initialSyncLimit, + }); + }, +}; + +export const matrixSetupWizard: ChannelSetupWizard = { + channel, + resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID, + resolveShouldPromptAccountIds: () => false, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs homeserver + access token or password", + configuredHint: "configured", + unconfiguredHint: "needs auth", + resolveConfigured: ({ cfg }) => resolveMatrixAccount({ cfg: cfg as CoreConfig }).configured, + resolveStatusLines: ({ cfg }) => { + const configured = resolveMatrixAccount({ cfg: cfg as CoreConfig }).configured; + return [ + `Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`, + ]; + }, + resolveSelectionHint: ({ cfg, configured }) => { + if (!isMatrixSdkAvailable()) { + return "install @vector-im/matrix-bot-sdk"; + } + return configured ? "configured" : "needs auth"; + }, + }, + credentials: [], + finalize: async ({ cfg, runtime, prompter, forceAllowFrom }) => { let next = cfg as CoreConfig; await ensureMatrixSdkInstalled({ runtime, @@ -231,16 +347,11 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { initialValue: true, }); if (useEnv) { - next = { - ...next, - channels: { - ...next.channels, - matrix: { - ...next.channels?.matrix, - enabled: true, - }, - }, - }; + next = matrixSetupAdapter.applyAccountConfig({ + cfg: next, + accountId: DEFAULT_ACCOUNT_ID, + input: { useEnv: true }, + }) as CoreConfig; if (forceAllowFrom) { next = await promptMatrixAllowFrom({ cfg: next, prompter }); } @@ -284,7 +395,6 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { } if (!accessToken && !passwordConfigured()) { - // Ask auth method FIRST before asking for user ID const authMode = await prompter.select({ message: "Matrix auth method", options: [ @@ -300,11 +410,8 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { validate: (value) => (value?.trim() ? undefined : "Required"), }), ).trim(); - // With access token, we can fetch the userId automatically - don't prompt for it - // The client.ts will use whoami() to get it userId = ""; } else { - // Password auth requires user ID upfront userId = String( await prompter.text({ message: "Matrix user ID", @@ -333,7 +440,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { const passwordResult = await promptSingleChannelSecretInput({ cfg: next, prompter, - providerHint: "matrix", + providerHint: channel, credentialLabel: "password", accountConfigured: passwordPromptState.accountConfigured, canUseEnv: passwordPromptState.canUseEnv, @@ -359,7 +466,6 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { }), ).trim(); - // Ask about E2EE encryption const enableEncryption = await prompter.confirm({ message: "Enable end-to-end encryption (E2EE)?", initialValue: existing.encryption ?? false, @@ -375,7 +481,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { homeserver, userId: userId || undefined, accessToken: accessToken || undefined, - password: password, + password, deviceName: deviceName || undefined, encryption: enableEncryption || undefined, }, @@ -451,7 +557,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { return { cfg: next }; }, - dmPolicy, + dmPolicy: matrixDmPolicy, disable: (cfg) => ({ ...(cfg as CoreConfig), channels: { diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index a5c8f0bbe58..a4e62e5e310 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -16,7 +16,6 @@ import { MSTeamsConfigSchema, PAIRING_APPROVED_MESSAGE, } from "openclaw/plugin-sdk/msteams"; -import { msteamsOnboardingAdapter } from "./onboarding.js"; import { resolveMSTeamsGroupToolPolicy } from "./policy.js"; import { normalizeMSTeamsMessagingTarget, @@ -27,6 +26,7 @@ import { resolveMSTeamsUserAllowlist, } from "./resolve-allowlist.js"; import { getMSTeamsRuntime } from "./runtime.js"; +import { msteamsSetupAdapter, msteamsSetupWizard } from "./setup-surface.js"; import { resolveMSTeamsCredentials } from "./token.js"; type ResolvedMSTeamsAccount = { @@ -56,7 +56,7 @@ export const msteamsPlugin: ChannelPlugin = { ...meta, aliases: [...meta.aliases], }, - onboarding: msteamsOnboardingAdapter, + setupWizard: msteamsSetupWizard, pairing: { idLabel: "msteamsUserId", normalizeAllowEntry: (entry) => entry.replace(/^(msteams|user):/i, ""), @@ -145,19 +145,7 @@ export const msteamsPlugin: ChannelPlugin = { }); }, }, - setup: { - resolveAccountId: () => DEFAULT_ACCOUNT_ID, - applyAccountConfig: ({ cfg }) => ({ - ...cfg, - channels: { - ...cfg.channels, - msteams: { - ...cfg.channels?.msteams, - enabled: true, - }, - }, - }), - }, + setup: msteamsSetupAdapter, messaging: { normalizeTarget: normalizeMSTeamsMessagingTarget, targetResolver: { diff --git a/extensions/msteams/src/onboarding.ts b/extensions/msteams/src/setup-surface.ts similarity index 80% rename from extensions/msteams/src/onboarding.ts rename to extensions/msteams/src/setup-surface.ts index 11207e8ee49..8d5ebdbb5ef 100644 --- a/extensions/msteams/src/onboarding.ts +++ b/extensions/msteams/src/setup-surface.ts @@ -1,21 +1,19 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, - OpenClawConfig, - DmPolicy, - WizardPrompter, - MSTeamsTeamConfig, -} from "openclaw/plugin-sdk/msteams"; +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js"; import { - DEFAULT_ACCOUNT_ID, - formatDocsLink, mergeAllowFromEntries, - promptChannelAccessConfig, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, splitOnboardingEntries, -} from "openclaw/plugin-sdk/msteams"; +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DmPolicy, MSTeamsTeamConfig } from "../../../src/config/types.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { parseMSTeamsTeamEntry, resolveMSTeamsChannelAllowlist, @@ -29,7 +27,7 @@ const channel = "msteams" as const; function setMSTeamsDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { return setTopLevelChannelDmPolicyWithAllowFrom({ cfg, - channel: "msteams", + channel, dmPolicy, }); } @@ -37,7 +35,7 @@ function setMSTeamsDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { function setMSTeamsAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { return setTopLevelChannelAllowFrom({ cfg, - channel: "msteams", + channel, allowFrom, }); } @@ -138,7 +136,7 @@ async function promptMSTeamsAllowFrom(params: { async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise { await prompter.note( [ - "1) Azure Bot registration → get App ID + Tenant ID", + "1) Azure Bot registration -> get App ID + Tenant ID", "2) Add a client secret (App Password)", "3) Set webhook URL + messaging endpoint", "Tip: you can also set MSTEAMS_APP_ID / MSTEAMS_APP_PASSWORD / MSTEAMS_TENANT_ID.", @@ -154,7 +152,7 @@ function setMSTeamsGroupPolicy( ): OpenClawConfig { return setTopLevelChannelGroupPolicy({ cfg, - channel: "msteams", + channel, groupPolicy, enabled: true, }); @@ -193,7 +191,7 @@ function setMSTeamsTeamsAllowlist( }; } -const dmPolicy: ChannelOnboardingDmPolicy = { +const msteamsDmPolicy: ChannelOnboardingDmPolicy = { label: "MS Teams", channel, policyKey: "channels.msteams.dmPolicy", @@ -203,21 +201,46 @@ const dmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptMSTeamsAllowFrom, }; -export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = { +export const msteamsSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountConfig: ({ cfg }) => ({ + ...cfg, + channels: { + ...cfg.channels, + msteams: { + ...cfg.channels?.msteams, + enabled: true, + }, + }, + }), +}; + +export const msteamsSetupWizard: ChannelSetupWizard = { channel, - getStatus: async ({ cfg }) => { - const configured = - Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)) || - hasConfiguredMSTeamsCredentials(cfg.channels?.msteams); - return { - channel, - configured, - statusLines: [`MS Teams: ${configured ? "configured" : "needs app credentials"}`], - selectionHint: configured ? "configured" : "needs app creds", - quickstartScore: configured ? 2 : 0, - }; + resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID, + resolveShouldPromptAccountIds: () => false, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs app credentials", + configuredHint: "configured", + unconfiguredHint: "needs app creds", + configuredScore: 2, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => { + return ( + Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)) || + hasConfiguredMSTeamsCredentials(cfg.channels?.msteams) + ); + }, + resolveStatusLines: ({ cfg }) => { + const configured = + Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)) || + hasConfiguredMSTeamsCredentials(cfg.channels?.msteams); + return [`MS Teams: ${configured ? "configured" : "needs app credentials"}`]; + }, }, - configure: async ({ cfg, prompter }) => { + credentials: [], + finalize: async ({ cfg, prompter }) => { const resolved = resolveMSTeamsCredentials(cfg.channels?.msteams); const hasConfigCreds = hasConfiguredMSTeamsCredentials(cfg.channels?.msteams); const canUseEnv = Boolean( @@ -243,13 +266,11 @@ export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = { initialValue: true, }); if (keepEnv) { - next = { - ...next, - channels: { - ...next.channels, - msteams: { ...next.channels?.msteams, enabled: true }, - }, - }; + next = msteamsSetupAdapter.applyAccountConfig({ + cfg: next, + accountId: DEFAULT_ACCOUNT_ID, + input: {}, + }); } else { ({ appId, appPassword, tenantId } = await promptMSTeamsCredentials(prompter)); } @@ -308,17 +329,17 @@ export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = { .filter(Boolean) as Array<{ teamKey: string; channelKey?: string }>; if (accessConfig.entries.length > 0 && resolveMSTeamsCredentials(next.channels?.msteams)) { try { - const resolved = await resolveMSTeamsChannelAllowlist({ + const resolvedEntries = await resolveMSTeamsChannelAllowlist({ cfg: next, entries: accessConfig.entries, }); - const resolvedChannels = resolved.filter( + const resolvedChannels = resolvedEntries.filter( (entry) => entry.resolved && entry.teamId && entry.channelId, ); - const resolvedTeams = resolved.filter( + const resolvedTeams = resolvedEntries.filter( (entry) => entry.resolved && entry.teamId && !entry.channelId, ); - const unresolved = resolved + const unresolved = resolvedEntries .filter((entry) => !entry.resolved) .map((entry) => entry.input); @@ -370,7 +391,7 @@ export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = { return { cfg: next, accountId: DEFAULT_ACCOUNT_ID }; }, - dmPolicy, + dmPolicy: msteamsDmPolicy, disable: (cfg) => ({ ...cfg, channels: { diff --git a/extensions/twitch/src/onboarding.test.ts b/extensions/twitch/src/onboarding.test.ts index b8946eefc49..47b4e179e5e 100644 --- a/extensions/twitch/src/onboarding.test.ts +++ b/extensions/twitch/src/onboarding.test.ts @@ -1,5 +1,5 @@ /** - * Tests for onboarding.ts helpers + * Tests for setup-surface.ts helpers * * Tests cover: * - promptToken helper @@ -15,11 +15,6 @@ import type { WizardPrompter } from "openclaw/plugin-sdk/twitch"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { TwitchAccountConfig } from "./types.js"; -vi.mock("openclaw/plugin-sdk/twitch", () => ({ - formatDocsLink: (url: string, fallback: string) => fallback || url, - promptChannelAccessConfig: vi.fn(async () => null), -})); - // Mock the helpers we're testing const mockPromptText = vi.fn(); const mockPromptConfirm = vi.fn(); @@ -35,7 +30,7 @@ const mockAccount: TwitchAccountConfig = { channel: "#testchannel", }; -describe("onboarding helpers", () => { +describe("setup surface helpers", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -46,7 +41,7 @@ describe("onboarding helpers", () => { describe("promptToken", () => { it("should return existing token when user confirms to keep it", async () => { - const { promptToken } = await import("./onboarding.js"); + const { promptToken } = await import("./setup-surface.js"); mockPromptConfirm.mockResolvedValue(true); @@ -61,7 +56,7 @@ describe("onboarding helpers", () => { }); it("should prompt for new token when user doesn't keep existing", async () => { - const { promptToken } = await import("./onboarding.js"); + const { promptToken } = await import("./setup-surface.js"); mockPromptConfirm.mockResolvedValue(false); mockPromptText.mockResolvedValue("oauth:newtoken123"); @@ -77,7 +72,7 @@ describe("onboarding helpers", () => { }); it("should use env token as initial value when provided", async () => { - const { promptToken } = await import("./onboarding.js"); + const { promptToken } = await import("./setup-surface.js"); mockPromptConfirm.mockResolvedValue(false); mockPromptText.mockResolvedValue("oauth:fromenv"); @@ -92,7 +87,7 @@ describe("onboarding helpers", () => { }); it("should validate token format", async () => { - const { promptToken } = await import("./onboarding.js"); + const { promptToken } = await import("./setup-surface.js"); // Set up mocks - user doesn't want to keep existing token mockPromptConfirm.mockResolvedValueOnce(false); @@ -124,7 +119,7 @@ describe("onboarding helpers", () => { }); it("should return early when no existing token and no env token", async () => { - const { promptToken } = await import("./onboarding.js"); + const { promptToken } = await import("./setup-surface.js"); mockPromptText.mockResolvedValue("oauth:newtoken"); @@ -137,7 +132,7 @@ describe("onboarding helpers", () => { describe("promptUsername", () => { it("should prompt for username with validation", async () => { - const { promptUsername } = await import("./onboarding.js"); + const { promptUsername } = await import("./setup-surface.js"); mockPromptText.mockResolvedValue("mybot"); @@ -152,7 +147,7 @@ describe("onboarding helpers", () => { }); it("should use existing username as initial value", async () => { - const { promptUsername } = await import("./onboarding.js"); + const { promptUsername } = await import("./setup-surface.js"); mockPromptText.mockResolvedValue("testbot"); @@ -168,7 +163,7 @@ describe("onboarding helpers", () => { describe("promptClientId", () => { it("should prompt for client ID with validation", async () => { - const { promptClientId } = await import("./onboarding.js"); + const { promptClientId } = await import("./setup-surface.js"); mockPromptText.mockResolvedValue("abc123xyz"); @@ -185,7 +180,7 @@ describe("onboarding helpers", () => { describe("promptChannelName", () => { it("should return channel name when provided", async () => { - const { promptChannelName } = await import("./onboarding.js"); + const { promptChannelName } = await import("./setup-surface.js"); mockPromptText.mockResolvedValue("#mychannel"); @@ -195,7 +190,7 @@ describe("onboarding helpers", () => { }); it("should require a non-empty channel name", async () => { - const { promptChannelName } = await import("./onboarding.js"); + const { promptChannelName } = await import("./setup-surface.js"); mockPromptText.mockResolvedValue(""); @@ -210,7 +205,7 @@ describe("onboarding helpers", () => { describe("promptRefreshTokenSetup", () => { it("should return empty object when user declines", async () => { - const { promptRefreshTokenSetup } = await import("./onboarding.js"); + const { promptRefreshTokenSetup } = await import("./setup-surface.js"); mockPromptConfirm.mockResolvedValue(false); @@ -224,7 +219,7 @@ describe("onboarding helpers", () => { }); it("should prompt for credentials when user accepts", async () => { - const { promptRefreshTokenSetup } = await import("./onboarding.js"); + const { promptRefreshTokenSetup } = await import("./setup-surface.js"); mockPromptConfirm .mockResolvedValueOnce(true) // First call: useRefresh @@ -242,7 +237,7 @@ describe("onboarding helpers", () => { }); it("should use existing values as initial prompts", async () => { - const { promptRefreshTokenSetup } = await import("./onboarding.js"); + const { promptRefreshTokenSetup } = await import("./setup-surface.js"); const accountWithRefresh = { ...mockAccount, @@ -267,7 +262,7 @@ describe("onboarding helpers", () => { describe("configureWithEnvToken", () => { it("should return null when user declines env token", async () => { - const { configureWithEnvToken } = await import("./onboarding.js"); + const { configureWithEnvToken } = await import("./setup-surface.js"); // Reset and set up mock - user declines env token mockPromptConfirm.mockReset().mockResolvedValue(false as never); @@ -287,7 +282,7 @@ describe("onboarding helpers", () => { }); it("should prompt for username and clientId when using env token", async () => { - const { configureWithEnvToken } = await import("./onboarding.js"); + const { configureWithEnvToken } = await import("./setup-surface.js"); // Reset and set up mocks - user accepts env token mockPromptConfirm.mockReset().mockResolvedValue(true as never); diff --git a/extensions/twitch/src/plugin.ts b/extensions/twitch/src/plugin.ts index 11cf90b8893..3958a05fd8b 100644 --- a/extensions/twitch/src/plugin.ts +++ b/extensions/twitch/src/plugin.ts @@ -12,10 +12,10 @@ import { twitchMessageActions } from "./actions.js"; import { removeClientManager } from "./client-manager-registry.js"; import { TwitchConfigSchema } from "./config-schema.js"; import { DEFAULT_ACCOUNT_ID, getAccountConfig, listAccountIds } from "./config.js"; -import { twitchOnboardingAdapter } from "./onboarding.js"; import { twitchOutbound } from "./outbound.js"; import { probeTwitch } from "./probe.js"; import { resolveTwitchTargets } from "./resolver.js"; +import { twitchSetupAdapter, twitchSetupWizard } from "./setup-surface.js"; import { collectTwitchStatusIssues } from "./status.js"; import { resolveTwitchToken } from "./token.js"; import type { @@ -51,8 +51,9 @@ export const twitchPlugin: ChannelPlugin = { aliases: ["twitch-chat"], } satisfies ChannelMeta, - /** Onboarding adapter */ - onboarding: twitchOnboardingAdapter, + /** Setup wizard surface */ + setup: twitchSetupAdapter, + setupWizard: twitchSetupWizard, /** Pairing configuration */ pairing: { diff --git a/extensions/twitch/src/onboarding.ts b/extensions/twitch/src/setup-surface.ts similarity index 76% rename from extensions/twitch/src/onboarding.ts rename to extensions/twitch/src/setup-surface.ts index 060857bf383..776644a2d23 100644 --- a/extensions/twitch/src/onboarding.ts +++ b/extensions/twitch/src/setup-surface.ts @@ -1,25 +1,21 @@ /** - * Twitch onboarding adapter for CLI setup wizard. + * Twitch setup wizard surface for CLI setup. */ -import type { OpenClawConfig } from "openclaw/plugin-sdk/twitch"; -import { - formatDocsLink, - promptChannelAccessConfig, - type ChannelOnboardingAdapter, - type ChannelOnboardingDmPolicy, - type WizardPrompter, -} from "openclaw/plugin-sdk/twitch"; +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js"; import type { TwitchAccountConfig, TwitchRole } from "./types.js"; import { isAccountConfigured } from "./utils/twitch.js"; const channel = "twitch" as const; -/** - * Set Twitch account configuration - */ -function setTwitchAccount( +export function setTwitchAccount( cfg: OpenClawConfig, account: Partial, ): OpenClawConfig { @@ -59,9 +55,6 @@ function setTwitchAccount( }; } -/** - * Note about Twitch setup - */ async function noteTwitchSetupHelp(prompter: WizardPrompter): Promise { await prompter.note( [ @@ -77,17 +70,13 @@ async function noteTwitchSetupHelp(prompter: WizardPrompter): Promise { ); } -/** - * Prompt for Twitch OAuth token with early returns. - */ -async function promptToken( +export async function promptToken( prompter: WizardPrompter, account: TwitchAccountConfig | null, envToken: string | undefined, ): Promise { const existingToken = account?.accessToken ?? ""; - // If we have an existing token and no env var, ask if we should keep it if (existingToken && !envToken) { const keepToken = await prompter.confirm({ message: "Access token already configured. Keep it?", @@ -98,7 +87,6 @@ async function promptToken( } } - // Prompt for new token return String( await prompter.text({ message: "Twitch OAuth token (oauth:...)", @@ -117,10 +105,7 @@ async function promptToken( ).trim(); } -/** - * Prompt for Twitch username. - */ -async function promptUsername( +export async function promptUsername( prompter: WizardPrompter, account: TwitchAccountConfig | null, ): Promise { @@ -133,10 +118,7 @@ async function promptUsername( ).trim(); } -/** - * Prompt for Twitch Client ID. - */ -async function promptClientId( +export async function promptClientId( prompter: WizardPrompter, account: TwitchAccountConfig | null, ): Promise { @@ -149,27 +131,20 @@ async function promptClientId( ).trim(); } -/** - * Prompt for optional channel name. - */ -async function promptChannelName( +export async function promptChannelName( prompter: WizardPrompter, account: TwitchAccountConfig | null, ): Promise { - const channelName = String( + return String( await prompter.text({ message: "Channel to join", initialValue: account?.channel ?? "", validate: (value) => (value?.trim() ? undefined : "Required"), }), ).trim(); - return channelName; } -/** - * Prompt for token refresh credentials (client secret and refresh token). - */ -async function promptRefreshTokenSetup( +export async function promptRefreshTokenSetup( prompter: WizardPrompter, account: TwitchAccountConfig | null, ): Promise<{ clientSecret?: string; refreshToken?: string }> { @@ -203,10 +178,7 @@ async function promptRefreshTokenSetup( return { clientSecret, refreshToken }; } -/** - * Configure with env token path (returns early if user chooses env token). - */ -async function configureWithEnvToken( +export async function configureWithEnvToken( cfg: OpenClawConfig, prompter: WizardPrompter, account: TwitchAccountConfig | null, @@ -228,7 +200,7 @@ async function configureWithEnvToken( const cfgWithAccount = setTwitchAccount(cfg, { username, clientId, - accessToken: "", // Will use env var + accessToken: "", enabled: true, }); @@ -239,9 +211,6 @@ async function configureWithEnvToken( return { cfg: cfgWithAccount }; } -/** - * Set Twitch access control (role-based) - */ function setTwitchAccessControl( cfg: OpenClawConfig, allowedRoles: TwitchRole[], @@ -259,14 +228,13 @@ function setTwitchAccessControl( }); } -const dmPolicy: ChannelOnboardingDmPolicy = { +const twitchDmPolicy: ChannelOnboardingDmPolicy = { label: "Twitch", channel, - policyKey: "channels.twitch.allowedRoles", // Twitch uses roles instead of DM policy + policyKey: "channels.twitch.allowedRoles", allowFromKey: "channels.twitch.accounts.default.allowFrom", getCurrent: (cfg) => { - const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID); - // Map allowedRoles to policy equivalent + const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); if (account?.allowedRoles?.includes("all")) { return "open"; } @@ -278,10 +246,10 @@ const dmPolicy: ChannelOnboardingDmPolicy = { setPolicy: (cfg, policy) => { const allowedRoles: TwitchRole[] = policy === "open" ? ["all"] : policy === "allowlist" ? [] : ["moderator"]; - return setTwitchAccessControl(cfg, allowedRoles, true); + return setTwitchAccessControl(cfg as OpenClawConfig, allowedRoles, true); }, promptAllowFrom: async ({ cfg, prompter }) => { - const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID); + const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); const existingAllowFrom = account?.allowFrom ?? []; const entry = await prompter.text({ @@ -295,28 +263,43 @@ const dmPolicy: ChannelOnboardingDmPolicy = { .map((s) => s.trim()) .filter(Boolean); - return setTwitchAccount(cfg, { + return setTwitchAccount(cfg as OpenClawConfig, { ...(account ?? undefined), allowFrom, }); }, }; -export const twitchOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID); - const configured = account ? isAccountConfigured(account) : false; +export const twitchSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountConfig: ({ cfg }) => + setTwitchAccount(cfg, { + enabled: true, + }), +}; - return { - channel, - configured, - statusLines: [`Twitch: ${configured ? "configured" : "needs username, token, and clientId"}`], - selectionHint: configured ? "configured" : "needs setup", - }; +export const twitchSetupWizard: ChannelSetupWizard = { + channel, + resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID, + resolveShouldPromptAccountIds: () => false, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs username, token, and clientId", + configuredHint: "configured", + unconfiguredHint: "needs setup", + resolveConfigured: ({ cfg }) => { + const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); + return account ? isAccountConfigured(account) : false; + }, + resolveStatusLines: ({ cfg }) => { + const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); + const configured = account ? isAccountConfigured(account) : false; + return [`Twitch: ${configured ? "configured" : "needs username, token, and clientId"}`]; + }, }, - configure: async ({ cfg, prompter, forceAllowFrom }) => { - const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID); + credentials: [], + finalize: async ({ cfg, prompter, forceAllowFrom }) => { + const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); if (!account || !isAccountConfigured(account)) { await noteTwitchSetupHelp(prompter); @@ -324,29 +307,27 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = { const envToken = process.env.OPENCLAW_TWITCH_ACCESS_TOKEN?.trim(); - // Check if env var is set and config is empty if (envToken && !account?.accessToken) { const envResult = await configureWithEnvToken( - cfg, + cfg as OpenClawConfig, prompter, account, envToken, forceAllowFrom, - dmPolicy, + twitchDmPolicy, ); if (envResult) { return envResult; } } - // Prompt for credentials const username = await promptUsername(prompter, account); const token = await promptToken(prompter, account, envToken); const clientId = await promptClientId(prompter, account); const channelName = await promptChannelName(prompter, account); const { clientSecret, refreshToken } = await promptRefreshTokenSetup(prompter, account); - const cfgWithAccount = setTwitchAccount(cfg, { + const cfgWithAccount = setTwitchAccount(cfg as OpenClawConfig, { username, accessToken: token, clientId, @@ -357,11 +338,10 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = { }); const cfgWithAllowFrom = - forceAllowFrom && dmPolicy.promptAllowFrom - ? await dmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter }) + forceAllowFrom && twitchDmPolicy.promptAllowFrom + ? await twitchDmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter }) : cfgWithAccount; - // Prompt for access control if allowFrom not set if (!account?.allowFrom || account.allowFrom.length === 0) { const accessConfig = await promptChannelAccessConfig({ prompter, @@ -384,14 +364,15 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = { ? ["moderator", "vip"] : []; - const cfgWithAccessControl = setTwitchAccessControl(cfgWithAllowFrom, allowedRoles, true); - return { cfg: cfgWithAccessControl }; + return { + cfg: setTwitchAccessControl(cfgWithAllowFrom, allowedRoles, true), + }; } } return { cfg: cfgWithAllowFrom }; }, - dmPolicy, + dmPolicy: twitchDmPolicy, disable: (cfg) => { const twitch = (cfg.channels as Record)?.twitch as | Record @@ -405,13 +386,3 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = { }; }, }; - -// Export helper functions for testing -export { - promptToken, - promptUsername, - promptClientId, - promptChannelName, - promptRefreshTokenSetup, - configureWithEnvToken, -}; diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index ba4cad93a92..52d18e4665f 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -32,11 +32,6 @@ export { } from "../channels/plugins/config-helpers.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; -export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js"; export { buildSingleChannelSecretPromptState, addWildcardAllowFrom, @@ -113,3 +108,7 @@ export { buildProbeChannelStatusSummary, collectStatusIssuesFromLastError, } from "./status-helpers.js"; +export { + matrixSetupAdapter, + matrixSetupWizard, +} from "../../extensions/matrix/src/setup-surface.js"; diff --git a/src/plugin-sdk/msteams.ts b/src/plugin-sdk/msteams.ts index b73aec7c779..d99f703ed64 100644 --- a/src/plugin-sdk/msteams.ts +++ b/src/plugin-sdk/msteams.ts @@ -32,11 +32,6 @@ export { export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export { buildMediaPayload } from "../channels/plugins/media-payload.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; -export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js"; export { addWildcardAllowFrom, mergeAllowFromEntries, @@ -122,3 +117,7 @@ export { createDefaultChannelRuntimeState, } from "./status-helpers.js"; export { normalizeStringEntries } from "../shared/string-normalization.js"; +export { + msteamsSetupAdapter, + msteamsSetupWizard, +} from "../../extensions/msteams/src/setup-surface.js"; diff --git a/src/plugin-sdk/twitch.ts b/src/plugin-sdk/twitch.ts index 7ea8a9f5f4b..907cdd171fa 100644 --- a/src/plugin-sdk/twitch.ts +++ b/src/plugin-sdk/twitch.ts @@ -22,11 +22,6 @@ export type { ChannelStatusIssue, } from "../channels/plugins/types.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; -export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js"; export { createReplyPrefixOptions } from "../channels/reply-prefix.js"; export type { OpenClawConfig } from "../config/config.js"; export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; @@ -38,3 +33,7 @@ export type { OpenClawPluginApi } from "../plugins/types.js"; export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; +export { + twitchSetupAdapter, + twitchSetupWizard, +} from "../../extensions/twitch/src/setup-surface.js"; From 26a8aee01cfbdf15e23c13eb2d62841aa119e6bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 18:23:18 -0700 Subject: [PATCH 096/943] refactor: drop channel onboarding fallback --- docs/tools/plugin.md | 15 ------------- src/channels/plugins/types.plugin.ts | 3 --- src/commands/onboard-channels.e2e.test.ts | 20 +++++++++-------- src/commands/onboard-channels.ts | 10 ++++++++- src/commands/onboarding/registry.ts | 3 --- src/plugin-sdk/subpaths.test.ts | 26 +++++++++++++++++++++++ 6 files changed, 46 insertions(+), 31 deletions(-) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 1cfe6ae1cd0..4113c9fbd05 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1437,16 +1437,6 @@ Preferred setup split: - `plugin.setup` owns account-id normalization, validation, and config writes. - `plugin.setupWizard` lets the host run the common wizard flow while the channel only supplies status, credential, DM allowlist, and channel-access descriptors. -Use `plugin.onboarding` only when the host-owned setup wizard cannot express the flow and the -channel needs to fully own prompting. - -Wizard precedence: - -1. `plugin.setupWizard` (preferred, host-owned prompts) -2. `plugin.onboarding.configureInteractive` -3. `plugin.onboarding.configureWhenConfigured` (already-configured channel only) -4. `plugin.onboarding.configure` - `plugin.setupWizard` is best for channels that fit the shared pattern: - one account picker driven by `plugin.config.listAccountIds` @@ -1458,11 +1448,6 @@ Wizard precedence: - optional DM allowlist resolution (for example `@username` -> numeric id) - optional completion note after setup finishes -`plugin.onboarding` hooks still return the same values as before: - -- `"skip"` leaves selection and account tracking unchanged. -- `{ cfg, accountId? }` applies config updates and records account selection. - ### Write a new messaging channel (step‑by‑step) Use this when you want a **new chat surface** (a "messaging channel"), not a model provider. diff --git a/src/channels/plugins/types.plugin.ts b/src/channels/plugins/types.plugin.ts index 3c821ab601b..cf09af29048 100644 --- a/src/channels/plugins/types.plugin.ts +++ b/src/channels/plugins/types.plugin.ts @@ -1,4 +1,3 @@ -import type { ChannelOnboardingAdapter } from "./onboarding-types.js"; import type { ChannelSetupWizard } from "./setup-wizard.js"; import type { ChannelAuthAdapter, @@ -57,8 +56,6 @@ export type ChannelPlugin; configSchema?: ChannelConfigSchema; diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index 6c505c6d4e2..c469f50a54e 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -472,15 +472,17 @@ describe("setupChannels", () => { )?.accounts?.[accountId] ?? { accountId }, setAccountEnabled, }, - onboarding: { - getStatus: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ - channel: "msteams", - configured: Boolean( - (cfg.channels?.msteams as { tenantId?: string } | undefined)?.tenantId, - ), - statusLines: [], - selectionHint: "configured", - })), + setupWizard: { + channel: "msteams", + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + resolveConfigured: ({ cfg }: { cfg: OpenClawConfig }) => + Boolean((cfg.channels?.msteams as { tenantId?: string } | undefined)?.tenantId), + resolveStatusLines: async () => [], + resolveSelectionHint: async () => "configured", + }, + credentials: [], }, outbound: { deliveryMode: "direct" }, }, diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 4a313ebf913..81deb95e901 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -5,6 +5,7 @@ import { getChannelSetupPlugin, listChannelSetupPlugins, } from "../channels/plugins/setup-registry.js"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../channels/plugins/setup-wizard.js"; import type { ChannelMeta, ChannelPlugin } from "../channels/plugins/types.js"; import { formatChannelPrimerLine, @@ -354,7 +355,14 @@ export async function setupChannels( if (adapter) { return adapter; } - return scopedPluginsById.get(channel)?.onboarding; + const scopedPlugin = scopedPluginsById.get(channel); + if (!scopedPlugin?.setupWizard) { + return undefined; + } + return buildChannelOnboardingAdapterFromSetupWizard({ + plugin: scopedPlugin, + wizard: scopedPlugin.setupWizard, + }); }; const preloadConfiguredExternalPlugins = () => { // Keep onboarding memory bounded by snapshot-loading only configured external plugins. diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts index 14074daf193..99009ee8fac 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/onboarding/registry.ts @@ -60,9 +60,6 @@ function resolveChannelOnboardingAdapter( setupWizardAdapters.set(plugin, adapter); return adapter; } - if (plugin.onboarding) { - return plugin.onboarding; - } return undefined; } diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 09341c4e82b..42d69512925 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -101,6 +101,12 @@ describe("plugin-sdk subpath exports", () => { expect("resolveWhatsAppMentionStripPatterns" in whatsappSdk).toBe(false); }); + it("exports Feishu helpers", async () => { + const feishuSdk = await import("openclaw/plugin-sdk/feishu"); + expect(typeof feishuSdk.feishuSetupWizard).toBe("object"); + expect(typeof feishuSdk.feishuSetupAdapter).toBe("object"); + }); + it("exports LINE helpers", () => { expect(typeof lineSdk.processLineMessage).toBe("function"); expect(typeof lineSdk.createInfoCard).toBe("function"); @@ -109,6 +115,8 @@ describe("plugin-sdk subpath exports", () => { it("exports Microsoft Teams helpers", () => { expect(typeof msteamsSdk.resolveControlCommandGate).toBe("function"); expect(typeof msteamsSdk.loadOutboundMediaFromUrl).toBe("function"); + expect(typeof msteamsSdk.msteamsSetupWizard).toBe("object"); + expect(typeof msteamsSdk.msteamsSetupAdapter).toBe("object"); }); it("exports Google Chat helpers", async () => { @@ -117,6 +125,18 @@ describe("plugin-sdk subpath exports", () => { expect(typeof googlechatSdk.googlechatSetupAdapter).toBe("object"); }); + it("exports Zalo helpers", async () => { + const zaloSdk = await import("openclaw/plugin-sdk/zalo"); + expect(typeof zaloSdk.zaloSetupWizard).toBe("object"); + expect(typeof zaloSdk.zaloSetupAdapter).toBe("object"); + }); + + it("exports Zalouser helpers", async () => { + const zalouserSdk = await import("openclaw/plugin-sdk/zalouser"); + expect(typeof zalouserSdk.zalouserSetupWizard).toBe("object"); + expect(typeof zalouserSdk.zalouserSetupAdapter).toBe("object"); + }); + it("exports Tlon helpers", async () => { const tlonSdk = await import("openclaw/plugin-sdk/tlon"); expect(typeof tlonSdk.fetchWithSsrFGuard).toBe("function"); @@ -142,6 +162,10 @@ describe("plugin-sdk subpath exports", () => { const bluebubbles = await import("openclaw/plugin-sdk/bluebubbles"); expect(typeof bluebubbles.parseFiniteNumber).toBe("function"); + const matrix = await import("openclaw/plugin-sdk/matrix"); + expect(typeof matrix.matrixSetupWizard).toBe("object"); + expect(typeof matrix.matrixSetupAdapter).toBe("object"); + const mattermost = await import("openclaw/plugin-sdk/mattermost"); expect(typeof mattermost.parseStrictPositiveInteger).toBe("function"); @@ -151,6 +175,8 @@ describe("plugin-sdk subpath exports", () => { const twitch = await import("openclaw/plugin-sdk/twitch"); expect(typeof twitch.DEFAULT_ACCOUNT_ID).toBe("string"); expect(typeof twitch.normalizeAccountId).toBe("function"); + expect(typeof twitch.twitchSetupWizard).toBe("object"); + expect(typeof twitch.twitchSetupAdapter).toBe("object"); const zalo = await import("openclaw/plugin-sdk/zalo"); expect(typeof zalo.resolveClientIp).toBe("function"); From 1e196db49d8e0ad3cc246fc411d396df74ba393b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:27:03 +0000 Subject: [PATCH 097/943] fix: quiet discord startup logs --- extensions/discord/src/monitor/provider.test.ts | 7 ++++++- extensions/discord/src/monitor/provider.ts | 6 +++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index 8ded5f982ae..f00baf73ff8 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -46,10 +46,12 @@ const { resolveDiscordAllowlistConfigMock, resolveNativeCommandsEnabledMock, resolveNativeSkillsEnabledMock, + isVerboseMock, shouldLogVerboseMock, voiceRuntimeModuleLoadedMock, } = vi.hoisted(() => { const createdBindingManagers: Array<{ stop: ReturnType }> = []; + const isVerboseMock = vi.fn(() => false); const shouldLogVerboseMock = vi.fn(() => false); return { clientHandleDeployRequestMock: vi.fn(async () => undefined), @@ -112,6 +114,7 @@ const { })), resolveNativeCommandsEnabledMock: vi.fn(() => true), resolveNativeSkillsEnabledMock: vi.fn(() => false), + isVerboseMock, shouldLogVerboseMock, voiceRuntimeModuleLoadedMock: vi.fn(), }; @@ -213,6 +216,7 @@ vi.mock("../../../../src/config/config.js", () => ({ vi.mock("../../../../src/globals.js", () => ({ danger: (v: string) => v, + isVerbose: isVerboseMock, logVerbose: vi.fn(), shouldLogVerbose: shouldLogVerboseMock, warn: (v: string) => v, @@ -438,6 +442,7 @@ describe("monitorDiscordProvider", () => { }); resolveNativeCommandsEnabledMock.mockClear().mockReturnValue(true); resolveNativeSkillsEnabledMock.mockClear().mockReturnValue(false); + isVerboseMock.mockClear().mockReturnValue(false); shouldLogVerboseMock.mockClear().mockReturnValue(false); voiceRuntimeModuleLoadedMock.mockClear(); }); @@ -846,7 +851,7 @@ describe("monitorDiscordProvider", () => { emitter.emit("debug", "WebSocket connection opened"); return { id: "bot-1", username: "Molty" }; }); - shouldLogVerboseMock.mockReturnValue(true); + isVerboseMock.mockReturnValue(true); await monitorDiscordProvider({ config: baseConfig(), diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 4f8af71f0d5..d4ef01ab0d8 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -38,7 +38,7 @@ import { warnMissingProviderGroupPolicyFallbackOnce, } from "../../../../src/config/runtime-group-policy.js"; import { createConnectedChannelStatusPatch } from "../../../../src/gateway/channel-status-patches.js"; -import { danger, logVerbose, shouldLogVerbose, warn } from "../../../../src/globals.js"; +import { danger, isVerbose, logVerbose, shouldLogVerbose, warn } from "../../../../src/globals.js"; import { formatErrorMessage } from "../../../../src/infra/errors.js"; import { createSubsystemLogger } from "../../../../src/logging/subsystem.js"; import { getPluginCommandSpecs } from "../../../../src/plugins/commands.js"; @@ -363,7 +363,7 @@ function logDiscordStartupPhase(params: { gateway?: GatewayPlugin; details?: string; }) { - if (!shouldLogVerbose()) { + if (!isVerbose()) { return; } const elapsedMs = Math.max(0, Date.now() - params.startAt); @@ -775,7 +775,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const lifecycleGateway = client.getPlugin("gateway"); earlyGatewayEmitter = getDiscordGatewayEmitter(lifecycleGateway); onEarlyGatewayDebug = (msg: unknown) => { - if (!shouldLogVerbose()) { + if (!isVerbose()) { return; } runtime.log?.( From 961f42e0cf9def1b1771394928c5e95d4cb68f8b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 18:28:39 -0700 Subject: [PATCH 098/943] Slack: lazy-load setup wizard surface --- extensions/slack/src/channel.runtime.ts | 1 + extensions/slack/src/channel.ts | 10 +- extensions/slack/src/setup-core.ts | 495 ++++++++++++++++++++++++ extensions/slack/src/setup-surface.ts | 78 +--- src/plugin-sdk/index.ts | 3 +- src/plugin-sdk/slack.ts | 3 +- 6 files changed, 510 insertions(+), 80 deletions(-) create mode 100644 extensions/slack/src/channel.runtime.ts create mode 100644 extensions/slack/src/setup-core.ts diff --git a/extensions/slack/src/channel.runtime.ts b/extensions/slack/src/channel.runtime.ts new file mode 100644 index 00000000000..eefcc2c6215 --- /dev/null +++ b/extensions/slack/src/channel.runtime.ts @@ -0,0 +1 @@ +export { slackSetupWizard } from "./setup-surface.js"; diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 5903e5755b2..1a2232bb5e7 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -37,10 +37,14 @@ import { import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { getSlackRuntime } from "./runtime.js"; -import { slackSetupAdapter, slackSetupWizard } from "./setup-surface.js"; +import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; const meta = getChatChannelMeta("slack"); +async function loadSlackChannelRuntime() { + return await import("./channel.runtime.js"); +} + // Select the appropriate Slack token for read/write operations. function getTokenForOperation( account: ResolvedSlackAccount, @@ -106,6 +110,10 @@ const slackConfigBase = createScopedChannelConfigBase({ clearBaseFields: ["botToken", "appToken", "name"], }); +const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ + slackSetupWizard: (await loadSlackChannelRuntime()).slackSetupWizard, +})); + export const slackPlugin: ChannelPlugin = { id: "slack", meta: { diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts new file mode 100644 index 00000000000..0cf7903e6d4 --- /dev/null +++ b/extensions/slack/src/setup-core.ts @@ -0,0 +1,495 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + noteChannelLookupFailure, + noteChannelLookupSummary, + parseMentionOrPrefixedId, + patchChannelConfigForAccount, + setAccountGroupPolicyForChannel, + setLegacyChannelDmPolicyWithAllowFrom, + setOnboardingChannelEnabled, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { + ChannelSetupWizard, + ChannelSetupWizardAllowFromEntry, +} from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { inspectSlackAccount } from "./account-inspect.js"; +import { listSlackAccountIds, resolveSlackAccount, type ResolvedSlackAccount } from "./accounts.js"; + +const channel = "slack" as const; + +function buildSlackManifest(botName: string) { + const safeName = botName.trim() || "OpenClaw"; + const manifest = { + display_information: { + name: safeName, + description: `${safeName} connector for OpenClaw`, + }, + features: { + bot_user: { + display_name: safeName, + always_online: false, + }, + app_home: { + messages_tab_enabled: true, + messages_tab_read_only_enabled: false, + }, + slash_commands: [ + { + command: "/openclaw", + description: "Send a message to OpenClaw", + should_escape: false, + }, + ], + }, + oauth_config: { + scopes: { + bot: [ + "chat:write", + "channels:history", + "channels:read", + "groups:history", + "im:history", + "mpim:history", + "users:read", + "app_mentions:read", + "reactions:read", + "reactions:write", + "pins:read", + "pins:write", + "emoji:read", + "commands", + "files:read", + "files:write", + ], + }, + }, + settings: { + socket_mode_enabled: true, + event_subscriptions: { + bot_events: [ + "app_mention", + "message.channels", + "message.groups", + "message.im", + "message.mpim", + "reaction_added", + "reaction_removed", + "member_joined_channel", + "member_left_channel", + "channel_rename", + "pin_added", + "pin_removed", + ], + }, + }, + }; + return JSON.stringify(manifest, null, 2); +} + +function buildSlackSetupLines(botName = "OpenClaw"): string[] { + return [ + "1) Slack API -> Create App -> From scratch or From manifest (with the JSON below)", + "2) Add Socket Mode + enable it to get the app-level token (xapp-...)", + "3) Install App to workspace to get the xoxb- bot token", + "4) Enable Event Subscriptions (socket) for message events", + "5) App Home -> enable the Messages tab for DMs", + "Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.", + `Docs: ${formatDocsLink("/slack", "slack")}`, + "", + "Manifest (JSON):", + buildSlackManifest(botName), + ]; +} + +function enableSlackAccount(cfg: OpenClawConfig, accountId: string): OpenClawConfig { + return patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { enabled: true }, + }); +} + +function setSlackChannelAllowlist( + cfg: OpenClawConfig, + accountId: string, + channelKeys: string[], +): OpenClawConfig { + const channels = Object.fromEntries(channelKeys.map((key) => [key, { allow: true }])); + return patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { channels }, + }); +} + +function isSlackAccountConfigured(account: ResolvedSlackAccount): boolean { + const hasConfiguredBotToken = + Boolean(account.botToken?.trim()) || hasConfiguredSecretInput(account.config.botToken); + const hasConfiguredAppToken = + Boolean(account.appToken?.trim()) || hasConfiguredSecretInput(account.config.appToken); + return hasConfiguredBotToken && hasConfiguredAppToken; +} + +export const slackSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "Slack env tokens can only be used for the default account."; + } + if (!input.useEnv && (!input.botToken || !input.appToken)) { + return "Slack requires --bot-token and --app-token (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + slack: { + ...next.channels?.slack, + enabled: true, + ...(input.useEnv + ? {} + : { + ...(input.botToken ? { botToken: input.botToken } : {}), + ...(input.appToken ? { appToken: input.appToken } : {}), + }), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + slack: { + ...next.channels?.slack, + enabled: true, + accounts: { + ...next.channels?.slack?.accounts, + [accountId]: { + ...next.channels?.slack?.accounts?.[accountId], + enabled: true, + ...(input.botToken ? { botToken: input.botToken } : {}), + ...(input.appToken ? { appToken: input.appToken } : {}), + }, + }, + }, + }, + }; + }, +}; + +export function createSlackSetupWizardProxy( + loadWizard: () => Promise<{ slackSetupWizard: ChannelSetupWizard }>, +) { + const slackDmPolicy: ChannelOnboardingDmPolicy = { + label: "Slack", + channel, + policyKey: "channels.slack.dmPolicy", + allowFromKey: "channels.slack.allowFrom", + getCurrent: (cfg: OpenClawConfig) => + cfg.channels?.slack?.dmPolicy ?? cfg.channels?.slack?.dm?.policy ?? "pairing", + setPolicy: (cfg: OpenClawConfig, policy) => + setLegacyChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), + promptAllowFrom: async ({ cfg, prompter, accountId }) => { + const wizard = (await loadWizard()).slackSetupWizard; + if (!wizard.dmPolicy?.promptAllowFrom) { + return cfg; + } + return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId }); + }, + }; + + return { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs tokens", + configuredHint: "configured", + unconfiguredHint: "needs tokens", + configuredScore: 2, + unconfiguredScore: 1, + resolveConfigured: ({ cfg }) => + listSlackAccountIds(cfg).some((accountId) => { + const account = inspectSlackAccount({ cfg, accountId }); + return account.configured; + }), + }, + introNote: { + title: "Slack socket mode tokens", + lines: buildSlackSetupLines(), + shouldShow: ({ cfg, accountId }) => + !isSlackAccountConfigured(resolveSlackAccount({ cfg, accountId })), + }, + envShortcut: { + prompt: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?", + preferredEnvVar: "SLACK_BOT_TOKEN", + isAvailable: ({ cfg, accountId }) => + accountId === DEFAULT_ACCOUNT_ID && + Boolean(process.env.SLACK_BOT_TOKEN?.trim()) && + Boolean(process.env.SLACK_APP_TOKEN?.trim()) && + !isSlackAccountConfigured(resolveSlackAccount({ cfg, accountId })), + apply: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId), + }, + credentials: [ + { + inputKey: "botToken", + providerHint: "slack-bot", + credentialLabel: "Slack bot token", + preferredEnvVar: "SLACK_BOT_TOKEN", + envPrompt: "SLACK_BOT_TOKEN detected. Use env var?", + keepPrompt: "Slack bot token already configured. Keep it?", + inputPrompt: "Enter Slack bot token (xoxb-...)", + allowEnv: ({ accountId }: { accountId: string }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => { + const resolved = resolveSlackAccount({ cfg, accountId }); + return { + accountConfigured: + Boolean(resolved.botToken) || hasConfiguredSecretInput(resolved.config.botToken), + hasConfiguredValue: hasConfiguredSecretInput(resolved.config.botToken), + resolvedValue: resolved.botToken?.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID ? process.env.SLACK_BOT_TOKEN?.trim() : undefined, + }; + }, + applyUseEnv: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + enableSlackAccount(cfg, accountId), + applySet: ({ + cfg, + accountId, + value, + }: { + cfg: OpenClawConfig; + accountId: string; + value: unknown; + }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { + enabled: true, + botToken: value, + }, + }), + }, + { + inputKey: "appToken", + providerHint: "slack-app", + credentialLabel: "Slack app token", + preferredEnvVar: "SLACK_APP_TOKEN", + envPrompt: "SLACK_APP_TOKEN detected. Use env var?", + keepPrompt: "Slack app token already configured. Keep it?", + inputPrompt: "Enter Slack app token (xapp-...)", + allowEnv: ({ accountId }: { accountId: string }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => { + const resolved = resolveSlackAccount({ cfg, accountId }); + return { + accountConfigured: + Boolean(resolved.appToken) || hasConfiguredSecretInput(resolved.config.appToken), + hasConfiguredValue: hasConfiguredSecretInput(resolved.config.appToken), + resolvedValue: resolved.appToken?.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID ? process.env.SLACK_APP_TOKEN?.trim() : undefined, + }; + }, + applyUseEnv: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + enableSlackAccount(cfg, accountId), + applySet: ({ + cfg, + accountId, + value, + }: { + cfg: OpenClawConfig; + accountId: string; + value: unknown; + }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { + enabled: true, + appToken: value, + }, + }), + }, + ], + dmPolicy: slackDmPolicy, + allowFrom: { + helpTitle: "Slack allowlist", + helpLines: [ + "Allowlist Slack DMs by username (we resolve to user ids).", + "Examples:", + "- U12345678", + "- @alice", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/slack", "slack")}`, + ], + credentialInputKey: "botToken", + message: "Slack allowFrom (usernames or ids)", + placeholder: "@alice, U12345678", + invalidWithoutCredentialNote: "Slack token missing; use user ids (or mention form) only.", + parseId: (value: string) => + parseMentionOrPrefixedId({ + value, + mentionPattern: /^<@([A-Z0-9]+)>$/i, + prefixPattern: /^(slack:|user:)/i, + idPattern: /^[A-Z][A-Z0-9]+$/i, + normalizeId: (id) => id.toUpperCase(), + }), + resolveEntries: async ({ + cfg, + accountId, + credentialValues, + entries, + }: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: { botToken?: string }; + entries: string[]; + }) => { + const wizard = (await loadWizard()).slackSetupWizard; + if (!wizard.allowFrom) { + return entries.map((input) => ({ input, resolved: false, id: null })); + } + return await wizard.allowFrom.resolveEntries({ + cfg, + accountId, + credentialValues, + entries, + }); + }, + apply: ({ + cfg, + accountId, + allowFrom, + }: { + cfg: OpenClawConfig; + accountId: string; + allowFrom: string[]; + }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { dmPolicy: "allowlist", allowFrom }, + }), + }, + groupAccess: { + label: "Slack channels", + placeholder: "#general, #private, C123", + currentPolicy: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + resolveSlackAccount({ cfg, accountId }).config.groupPolicy ?? "allowlist", + currentEntries: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + Object.entries(resolveSlackAccount({ cfg, accountId }).config.channels ?? {}) + .filter(([, value]) => value?.allow !== false && value?.enabled !== false) + .map(([key]) => key), + updatePrompt: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + Boolean(resolveSlackAccount({ cfg, accountId }).config.channels), + setPolicy: ({ + cfg, + accountId, + policy, + }: { + cfg: OpenClawConfig; + accountId: string; + policy: "open" | "allowlist" | "disabled"; + }) => + setAccountGroupPolicyForChannel({ + cfg, + channel, + accountId, + groupPolicy: policy, + }), + resolveAllowlist: async ({ + cfg, + accountId, + credentialValues, + entries, + prompter, + }: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: { botToken?: string }; + entries: string[]; + prompter: { note: (message: string, title?: string) => Promise }; + }) => { + try { + const wizard = (await loadWizard()).slackSetupWizard; + if (!wizard.groupAccess) { + return entries; + } + return await wizard.groupAccess.resolveAllowlist({ + cfg, + accountId, + credentialValues, + entries, + prompter, + }); + } catch (error) { + await noteChannelLookupFailure({ + prompter, + label: "Slack channels", + error, + }); + await noteChannelLookupSummary({ + prompter, + label: "Slack channels", + resolvedSections: [], + unresolved: entries, + }); + return entries; + } + }, + applyAllowlist: ({ + cfg, + accountId, + resolved, + }: { + cfg: OpenClawConfig; + accountId: string; + resolved: unknown; + }) => setSlackChannelAllowlist(cfg, accountId, resolved as string[]), + }, + disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false), + } satisfies ChannelSetupWizard; +} diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index ad743ffa080..dafcad32f74 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -10,15 +10,10 @@ import { setLegacyChannelDmPolicyWithAllowFrom, setOnboardingChannelEnabled, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard, ChannelSetupWizardAllowFromEntry, } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; @@ -33,6 +28,7 @@ import { } from "./accounts.js"; import { resolveSlackChannelAllowlist } from "./resolve-channels.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; +import { slackSetupAdapter } from "./setup-core.js"; const channel = "slack" as const; @@ -238,78 +234,6 @@ function isSlackAccountConfigured(account: ResolvedSlackAccount): boolean { return hasConfiguredBotToken && hasConfiguredAppToken; } -export const slackSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "Slack env tokens can only be used for the default account."; - } - if (!input.useEnv && (!input.botToken || !input.appToken)) { - return "Slack requires --bot-token and --app-token (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - slack: { - ...next.channels?.slack, - enabled: true, - ...(input.useEnv - ? {} - : { - ...(input.botToken ? { botToken: input.botToken } : {}), - ...(input.appToken ? { appToken: input.appToken } : {}), - }), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - slack: { - ...next.channels?.slack, - enabled: true, - accounts: { - ...next.channels?.slack?.accounts, - [accountId]: { - ...next.channels?.slack?.accounts?.[accountId], - enabled: true, - ...(input.botToken ? { botToken: input.botToken } : {}), - ...(input.appToken ? { appToken: input.appToken } : {}), - }, - }, - }, - }, - }; - }, -}; - export const slackSetupWizard: ChannelSetupWizard = { channel, status: { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 089876dc7bc..90292907149 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -745,7 +745,8 @@ export { extractSlackToolSend, listSlackMessageActions, } from "../../extensions/slack/src/message-actions.js"; -export { slackSetupAdapter, slackSetupWizard } from "../../extensions/slack/src/setup-surface.js"; +export { slackSetupAdapter } from "../../extensions/slack/src/setup-core.js"; +export { slackSetupWizard } from "../../extensions/slack/src/setup-surface.js"; export { looksLikeSlackTargetId, normalizeSlackMessagingTarget, diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 779560b930b..7e200ab5995 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -39,7 +39,8 @@ export { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { slackSetupAdapter, slackSetupWizard } from "../../extensions/slack/src/setup-surface.js"; +export { slackSetupAdapter } from "../../extensions/slack/src/setup-core.js"; +export { slackSetupWizard } from "../../extensions/slack/src/setup-surface.js"; export { SlackConfigSchema } from "../config/zod-schema.providers-core.js"; export { handleSlackMessageAction } from "./slack-message-actions.js"; From 1c4f52d6a1164e437ba39458b7fd93c0c44248d9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 18:36:41 -0700 Subject: [PATCH 099/943] Feishu: drop stale runtime onboarding export --- extensions/feishu/src/channel.runtime.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/extensions/feishu/src/channel.runtime.ts b/extensions/feishu/src/channel.runtime.ts index 8068fb350d3..61f637a94de 100644 --- a/extensions/feishu/src/channel.runtime.ts +++ b/extensions/feishu/src/channel.runtime.ts @@ -1,5 +1,4 @@ export { listFeishuDirectoryGroupsLive, listFeishuDirectoryPeersLive } from "./directory.js"; -export { feishuOnboardingAdapter } from "./onboarding.js"; export { feishuOutbound } from "./outbound.js"; export { probeFeishu } from "./probe.js"; export { addReactionFeishu, listReactionsFeishu, removeReactionFeishu } from "./reactions.js"; From d663df7a7445fe744bb959c0338ffef4411470dc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 18:36:57 -0700 Subject: [PATCH 100/943] Discord: lazy-load setup wizard surface --- extensions/discord/src/channel.runtime.ts | 1 + extensions/discord/src/channel.ts | 10 +- extensions/discord/src/setup-core.ts | 348 ++++++++++++++++++++++ extensions/discord/src/setup-surface.ts | 129 +------- src/plugin-sdk/discord.ts | 6 +- src/plugin-sdk/index.ts | 6 +- 6 files changed, 369 insertions(+), 131 deletions(-) create mode 100644 extensions/discord/src/channel.runtime.ts create mode 100644 extensions/discord/src/setup-core.ts diff --git a/extensions/discord/src/channel.runtime.ts b/extensions/discord/src/channel.runtime.ts new file mode 100644 index 00000000000..bc22b64706a --- /dev/null +++ b/extensions/discord/src/channel.runtime.ts @@ -0,0 +1 @@ +export { discordSetupWizard } from "./setup-surface.js"; diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 0123553fcb7..0af60e096bc 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -35,7 +35,7 @@ import { } from "openclaw/plugin-sdk/discord"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { getDiscordRuntime } from "./runtime.js"; -import { discordSetupAdapter, discordSetupWizard } from "./setup-surface.js"; +import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js"; type DiscordSendFn = ReturnType< typeof getDiscordRuntime @@ -43,6 +43,10 @@ type DiscordSendFn = ReturnType< const meta = getChatChannelMeta("discord"); +async function loadDiscordChannelRuntime() { + return await import("./channel.runtime.js"); +} + const discordMessageActions: ChannelMessageActionAdapter = { listActions: (ctx) => getDiscordRuntime().channel.discord.messageActions?.listActions?.(ctx) ?? [], @@ -73,6 +77,10 @@ const discordConfigBase = createScopedChannelConfigBase({ clearBaseFields: ["token", "name"], }); +const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({ + discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard, +})); + export const discordPlugin: ChannelPlugin = { id: "discord", meta: { diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts new file mode 100644 index 00000000000..cec63dd01ec --- /dev/null +++ b/extensions/discord/src/setup-core.ts @@ -0,0 +1,348 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + noteChannelLookupFailure, + noteChannelLookupSummary, + parseMentionOrPrefixedId, + patchChannelConfigForAccount, + setLegacyChannelDmPolicyWithAllowFrom, + setOnboardingChannelEnabled, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DiscordGuildEntry } from "../../../src/config/types.discord.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { inspectDiscordAccount } from "./account-inspect.js"; +import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js"; + +const channel = "discord" as const; + +export const DISCORD_TOKEN_HELP_LINES = [ + "1) Discord Developer Portal -> Applications -> New Application", + "2) Bot -> Add Bot -> Reset Token -> copy token", + "3) OAuth2 -> URL Generator -> scope 'bot' -> invite to your server", + "Tip: enable Message Content Intent if you need message text. (Bot -> Privileged Gateway Intents -> Message Content Intent)", + `Docs: ${formatDocsLink("/discord", "discord")}`, +]; + +export function setDiscordGuildChannelAllowlist( + cfg: OpenClawConfig, + accountId: string, + entries: Array<{ + guildKey: string; + channelKey?: string; + }>, +): OpenClawConfig { + const baseGuilds = + accountId === DEFAULT_ACCOUNT_ID + ? (cfg.channels?.discord?.guilds ?? {}) + : (cfg.channels?.discord?.accounts?.[accountId]?.guilds ?? {}); + const guilds: Record = { ...baseGuilds }; + for (const entry of entries) { + const guildKey = entry.guildKey || "*"; + const existing = guilds[guildKey] ?? {}; + if (entry.channelKey) { + const channels = { ...existing.channels }; + channels[entry.channelKey] = { allow: true }; + guilds[guildKey] = { ...existing, channels }; + } else { + guilds[guildKey] = existing; + } + } + return patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { guilds }, + }); +} + +export function parseDiscordAllowFromId(value: string): string | null { + return parseMentionOrPrefixedId({ + value, + mentionPattern: /^<@!?(\d+)>$/, + prefixPattern: /^(user:|discord:)/i, + idPattern: /^\d+$/, + }); +} + +export const discordSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "DISCORD_BOT_TOKEN can only be used for the default account."; + } + if (!input.useEnv && !input.token) { + return "Discord requires token (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + discord: { + ...next.channels?.discord, + enabled: true, + ...(input.useEnv ? {} : input.token ? { token: input.token } : {}), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + discord: { + ...next.channels?.discord, + enabled: true, + accounts: { + ...next.channels?.discord?.accounts, + [accountId]: { + ...next.channels?.discord?.accounts?.[accountId], + enabled: true, + ...(input.token ? { token: input.token } : {}), + }, + }, + }, + }, + }; + }, +}; + +export function createDiscordSetupWizardProxy( + loadWizard: () => Promise<{ discordSetupWizard: ChannelSetupWizard }>, +) { + const discordDmPolicy: ChannelOnboardingDmPolicy = { + label: "Discord", + channel, + policyKey: "channels.discord.dmPolicy", + allowFromKey: "channels.discord.allowFrom", + getCurrent: (cfg: OpenClawConfig) => + cfg.channels?.discord?.dmPolicy ?? cfg.channels?.discord?.dm?.policy ?? "pairing", + setPolicy: (cfg: OpenClawConfig, policy) => + setLegacyChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), + promptAllowFrom: async ({ cfg, prompter, accountId }) => { + const wizard = (await loadWizard()).discordSetupWizard; + if (!wizard.dmPolicy?.promptAllowFrom) { + return cfg; + } + return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId }); + }, + }; + + return { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs token", + configuredHint: "configured", + unconfiguredHint: "needs token", + configuredScore: 2, + unconfiguredScore: 1, + resolveConfigured: ({ cfg }) => + listDiscordAccountIds(cfg).some((accountId) => { + const account = inspectDiscordAccount({ cfg, accountId }); + return account.configured; + }), + }, + credentials: [ + { + inputKey: "token", + providerHint: channel, + credentialLabel: "Discord bot token", + preferredEnvVar: "DISCORD_BOT_TOKEN", + helpTitle: "Discord bot token", + helpLines: DISCORD_TOKEN_HELP_LINES, + envPrompt: "DISCORD_BOT_TOKEN detected. Use env var?", + keepPrompt: "Discord token already configured. Keep it?", + inputPrompt: "Enter Discord bot token", + allowEnv: ({ accountId }: { accountId: string }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => { + const account = inspectDiscordAccount({ cfg, accountId }); + return { + accountConfigured: account.configured, + hasConfiguredValue: account.tokenStatus !== "missing", + resolvedValue: account.token?.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.DISCORD_BOT_TOKEN?.trim() || undefined + : undefined, + }; + }, + }, + ], + groupAccess: { + label: "Discord channels", + placeholder: "My Server/#general, guildId/channelId, #support", + currentPolicy: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + resolveDiscordAccount({ cfg, accountId }).config.groupPolicy ?? "allowlist", + currentEntries: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + Object.entries(resolveDiscordAccount({ cfg, accountId }).config.guilds ?? {}).flatMap( + ([guildKey, value]) => { + const channels = value?.channels ?? {}; + const channelKeys = Object.keys(channels); + if (channelKeys.length === 0) { + const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey; + return [input]; + } + return channelKeys.map((channelKey) => `${guildKey}/${channelKey}`); + }, + ), + updatePrompt: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + Boolean(resolveDiscordAccount({ cfg, accountId }).config.guilds), + setPolicy: ({ + cfg, + accountId, + policy, + }: { + cfg: OpenClawConfig; + accountId: string; + policy: "open" | "allowlist" | "disabled"; + }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { groupPolicy: policy }, + }), + resolveAllowlist: async ({ + cfg, + accountId, + credentialValues, + entries, + prompter, + }: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: { token?: string }; + entries: string[]; + prompter: { note: (message: string, title?: string) => Promise }; + }) => { + const wizard = (await loadWizard()).discordSetupWizard; + if (!wizard.groupAccess) { + return entries.map((input) => ({ input, resolved: false })); + } + try { + return await wizard.groupAccess.resolveAllowlist({ + cfg, + accountId, + credentialValues, + entries, + prompter, + }); + } catch (error) { + await noteChannelLookupFailure({ + prompter, + label: "Discord channels", + error, + }); + await noteChannelLookupSummary({ + prompter, + label: "Discord channels", + resolvedSections: [], + unresolved: entries, + }); + return entries.map((input) => ({ input, resolved: false })); + } + }, + applyAllowlist: ({ + cfg, + accountId, + resolved, + }: { + cfg: OpenClawConfig; + accountId: string; + resolved: unknown; + }) => setDiscordGuildChannelAllowlist(cfg, accountId, resolved as never), + }, + allowFrom: { + credentialInputKey: "token", + helpTitle: "Discord allowlist", + helpLines: [ + "Allowlist Discord DMs by username (we resolve to user ids).", + "Examples:", + "- 123456789012345678", + "- @alice", + "- alice#1234", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/discord", "discord")}`, + ], + message: "Discord allowFrom (usernames or ids)", + placeholder: "@alice, 123456789012345678", + invalidWithoutCredentialNote: + "Bot token missing; use numeric user ids (or mention form) only.", + parseId: parseDiscordAllowFromId, + resolveEntries: async ({ + cfg, + accountId, + credentialValues, + entries, + }: { + cfg: OpenClawConfig; + accountId: string; + credentialValues: { token?: string }; + entries: string[]; + }) => { + const wizard = (await loadWizard()).discordSetupWizard; + if (!wizard.allowFrom) { + return entries.map((input) => ({ input, resolved: false, id: null })); + } + return await wizard.allowFrom.resolveEntries({ + cfg, + accountId, + credentialValues, + entries, + }); + }, + apply: async ({ + cfg, + accountId, + allowFrom, + }: { + cfg: OpenClawConfig; + accountId: string; + allowFrom: string[]; + }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { dmPolicy: "allowlist", allowFrom }, + }), + }, + dmPolicy: discordDmPolicy, + disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false), + } satisfies ChannelSetupWizard; +} diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index e03c7ef1e16..610b79a5efa 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -9,15 +9,9 @@ import { setLegacyChannelDmPolicyWithAllowFrom, setOnboardingChannelEnabled, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { DiscordGuildEntry } from "../../../src/config/types.discord.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { inspectDiscordAccount } from "./account-inspect.js"; @@ -32,58 +26,15 @@ import { type DiscordChannelResolution, } from "./resolve-channels.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; +import { + discordSetupAdapter, + DISCORD_TOKEN_HELP_LINES, + parseDiscordAllowFromId, + setDiscordGuildChannelAllowlist, +} from "./setup-core.js"; const channel = "discord" as const; -const DISCORD_TOKEN_HELP_LINES = [ - "1) Discord Developer Portal -> Applications -> New Application", - "2) Bot -> Add Bot -> Reset Token -> copy token", - "3) OAuth2 -> URL Generator -> scope 'bot' -> invite to your server", - "Tip: enable Message Content Intent if you need message text. (Bot -> Privileged Gateway Intents -> Message Content Intent)", - `Docs: ${formatDocsLink("/discord", "discord")}`, -]; - -function setDiscordGuildChannelAllowlist( - cfg: OpenClawConfig, - accountId: string, - entries: Array<{ - guildKey: string; - channelKey?: string; - }>, -): OpenClawConfig { - const baseGuilds = - accountId === DEFAULT_ACCOUNT_ID - ? (cfg.channels?.discord?.guilds ?? {}) - : (cfg.channels?.discord?.accounts?.[accountId]?.guilds ?? {}); - const guilds: Record = { ...baseGuilds }; - for (const entry of entries) { - const guildKey = entry.guildKey || "*"; - const existing = guilds[guildKey] ?? {}; - if (entry.channelKey) { - const channels = { ...existing.channels }; - channels[entry.channelKey] = { allow: true }; - guilds[guildKey] = { ...existing, channels }; - } else { - guilds[guildKey] = existing; - } - } - return patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { guilds }, - }); -} - -function parseDiscordAllowFromId(value: string): string | null { - return parseMentionOrPrefixedId({ - value, - mentionPattern: /^<@!?(\d+)>$/, - prefixPattern: /^(user:|discord:)/i, - idPattern: /^\d+$/, - }); -} - async function resolveDiscordAllowFromEntries(params: { token?: string; entries: string[] }) { if (!params.token?.trim()) { return params.entries.map((input) => ({ @@ -157,72 +108,6 @@ const discordDmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptDiscordAllowFrom, }; -export const discordSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "DISCORD_BOT_TOKEN can only be used for the default account."; - } - if (!input.useEnv && !input.token) { - return "Discord requires token (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - discord: { - ...next.channels?.discord, - enabled: true, - ...(input.useEnv ? {} : input.token ? { token: input.token } : {}), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - discord: { - ...next.channels?.discord, - enabled: true, - accounts: { - ...next.channels?.discord?.accounts, - [accountId]: { - ...next.channels?.discord?.accounts?.[accountId], - enabled: true, - ...(input.token ? { token: input.token } : {}), - }, - }, - }, - }, - }; - }, -}; - export const discordSetupWizard: ChannelSetupWizard = { channel, status: { diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index f4ffe6ef809..27f6c17bdff 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -35,10 +35,8 @@ export { resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { - discordSetupAdapter, - discordSetupWizard, -} from "../../extensions/discord/src/setup-surface.js"; +export { discordSetupWizard } from "../../extensions/discord/src/setup-surface.js"; +export { discordSetupAdapter } from "../../extensions/discord/src/setup-core.js"; export { DiscordConfigSchema } from "../config/zod-schema.providers-core.js"; export { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 90292907149..a6044a0da84 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -690,10 +690,8 @@ export { export { inspectDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; export type { InspectedDiscordAccount } from "../../extensions/discord/src/account-inspect.js"; export { collectDiscordAuditChannelIds } from "../../extensions/discord/src/audit.js"; -export { - discordSetupAdapter, - discordSetupWizard, -} from "../../extensions/discord/src/setup-surface.js"; +export { discordSetupWizard } from "../../extensions/discord/src/setup-surface.js"; +export { discordSetupAdapter } from "../../extensions/discord/src/setup-core.js"; export { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget, From de6666b8958f92f03a1b2f4b430248f27f13ddc9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 18:44:59 -0700 Subject: [PATCH 101/943] Signal: lazy-load setup wizard surface --- extensions/signal/src/channel.runtime.ts | 1 + extensions/signal/src/channel.ts | 10 +- extensions/signal/src/setup-core.ts | 275 +++++++++++++++++++++++ extensions/signal/src/setup-surface.ts | 141 +----------- src/plugin-sdk/index.ts | 6 +- src/plugin-sdk/signal.ts | 6 +- 6 files changed, 295 insertions(+), 144 deletions(-) create mode 100644 extensions/signal/src/channel.runtime.ts create mode 100644 extensions/signal/src/setup-core.ts diff --git a/extensions/signal/src/channel.runtime.ts b/extensions/signal/src/channel.runtime.ts new file mode 100644 index 00000000000..0403246478f --- /dev/null +++ b/extensions/signal/src/channel.runtime.ts @@ -0,0 +1 @@ +export { signalSetupWizard } from "./setup-surface.js"; diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index ccf635e60cf..8b2f0998ff9 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -28,7 +28,15 @@ import { } from "openclaw/plugin-sdk/signal"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { getSignalRuntime } from "./runtime.js"; -import { signalSetupAdapter, signalSetupWizard } from "./setup-surface.js"; +import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js"; + +async function loadSignalChannelRuntime() { + return await import("./channel.runtime.js"); +} + +const signalSetupWizard = createSignalSetupWizardProxy(async () => ({ + signalSetupWizard: (await loadSignalChannelRuntime()).signalSetupWizard, +})); const signalMessageActions: ChannelMessageActionAdapter = { listActions: (ctx) => getSignalRuntime().channel.signal.messageActions?.listActions?.(ctx) ?? [], diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts new file mode 100644 index 00000000000..2f46c4d4c4c --- /dev/null +++ b/extensions/signal/src/setup-core.ts @@ -0,0 +1,275 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + parseOnboardingEntriesAllowingWildcard, + promptParsedAllowFromForScopedChannel, + setChannelDmPolicyWithAllowFrom, + setOnboardingChannelEnabled, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { formatCliCommand } from "../../../src/cli/command-format.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { normalizeE164 } from "../../../src/utils.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { + listSignalAccountIds, + resolveDefaultSignalAccountId, + resolveSignalAccount, +} from "./accounts.js"; + +const channel = "signal" as const; +const MIN_E164_DIGITS = 5; +const MAX_E164_DIGITS = 15; +const DIGITS_ONLY = /^\d+$/; +const INVALID_SIGNAL_ACCOUNT_ERROR = + "Invalid E.164 phone number (must start with + and country code, e.g. +15555550123)"; + +export function normalizeSignalAccountInput(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + const normalized = normalizeE164(trimmed); + const digits = normalized.slice(1); + if (!DIGITS_ONLY.test(digits)) { + return null; + } + if (digits.length < MIN_E164_DIGITS || digits.length > MAX_E164_DIGITS) { + return null; + } + return `+${digits}`; +} + +function isUuidLike(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); +} + +export function parseSignalAllowFromEntries(raw: string): { entries: string[]; error?: string } { + return parseOnboardingEntriesAllowingWildcard(raw, (entry) => { + if (entry.toLowerCase().startsWith("uuid:")) { + const id = entry.slice("uuid:".length).trim(); + if (!id) { + return { error: "Invalid uuid entry" }; + } + return { value: `uuid:${id}` }; + } + if (isUuidLike(entry)) { + return { value: `uuid:${entry}` }; + } + const normalized = normalizeSignalAccountInput(entry); + if (!normalized) { + return { error: `Invalid entry: ${entry}` }; + } + return { value: normalized }; + }); +} + +function buildSignalSetupPatch(input: { + signalNumber?: string; + cliPath?: string; + httpUrl?: string; + httpHost?: string; + httpPort?: string; +}) { + return { + ...(input.signalNumber ? { account: input.signalNumber } : {}), + ...(input.cliPath ? { cliPath: input.cliPath } : {}), + ...(input.httpUrl ? { httpUrl: input.httpUrl } : {}), + ...(input.httpHost ? { httpHost: input.httpHost } : {}), + ...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}), + }; +} + +async function promptSignalAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + return promptParsedAllowFromForScopedChannel({ + cfg: params.cfg, + channel, + accountId: params.accountId, + defaultAccountId: resolveDefaultSignalAccountId(params.cfg), + prompter: params.prompter, + noteTitle: "Signal allowlist", + noteLines: [ + "Allowlist Signal DMs by sender id.", + "Examples:", + "- +15555550123", + "- uuid:123e4567-e89b-12d3-a456-426614174000", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/signal", "signal")}`, + ], + message: "Signal allowFrom (E.164 or uuid)", + placeholder: "+15555550123, uuid:123e4567-e89b-12d3-a456-426614174000", + parseEntries: parseSignalAllowFromEntries, + getExistingAllowFrom: ({ cfg, accountId }) => + resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? [], + }); +} + +export const signalSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ input }) => { + if ( + !input.signalNumber && + !input.httpUrl && + !input.httpHost && + !input.httpPort && + !input.cliPath + ) { + return "Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + signal: { + ...next.channels?.signal, + enabled: true, + ...buildSignalSetupPatch(input), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + signal: { + ...next.channels?.signal, + enabled: true, + accounts: { + ...next.channels?.signal?.accounts, + [accountId]: { + ...next.channels?.signal?.accounts?.[accountId], + enabled: true, + ...buildSignalSetupPatch(input), + }, + }, + }, + }, + }; + }, +}; + +export function createSignalSetupWizardProxy( + loadWizard: () => Promise<{ signalSetupWizard: ChannelSetupWizard }>, +) { + const signalDmPolicy: ChannelOnboardingDmPolicy = { + label: "Signal", + channel, + policyKey: "channels.signal.dmPolicy", + allowFromKey: "channels.signal.allowFrom", + getCurrent: (cfg: OpenClawConfig) => cfg.channels?.signal?.dmPolicy ?? "pairing", + setPolicy: (cfg: OpenClawConfig, policy) => + setChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), + promptAllowFrom: promptSignalAllowFrom, + }; + + return { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + configuredHint: "signal-cli found", + unconfiguredHint: "signal-cli missing", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => + listSignalAccountIds(cfg).some( + (accountId) => resolveSignalAccount({ cfg, accountId }).configured, + ), + resolveStatusLines: async (params) => + (await loadWizard()).signalSetupWizard.status.resolveStatusLines?.(params) ?? [], + resolveSelectionHint: async (params) => + await (await loadWizard()).signalSetupWizard.status.resolveSelectionHint?.(params), + resolveQuickstartScore: async (params) => + await (await loadWizard()).signalSetupWizard.status.resolveQuickstartScore?.(params), + }, + prepare: async (params) => await (await loadWizard()).signalSetupWizard.prepare?.(params), + credentials: [], + textInputs: [ + { + inputKey: "cliPath", + message: "signal-cli path", + currentValue: ({ cfg, accountId, credentialValues }) => + (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? + resolveSignalAccount({ cfg, accountId }).config.cliPath ?? + "signal-cli", + initialValue: ({ cfg, accountId, credentialValues }) => + (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? + resolveSignalAccount({ cfg, accountId }).config.cliPath ?? + "signal-cli", + shouldPrompt: async (params) => { + const input = (await loadWizard()).signalSetupWizard.textInputs?.find( + (entry) => entry.inputKey === "cliPath", + ); + return (await input?.shouldPrompt?.(params)) ?? false; + }, + confirmCurrentValue: false, + applyCurrentValue: true, + helpTitle: "Signal", + helpLines: [ + "signal-cli not found. Install it, then rerun this step or set channels.signal.cliPath.", + ], + }, + { + inputKey: "signalNumber", + message: "Signal bot number (E.164)", + currentValue: ({ cfg, accountId }) => + normalizeSignalAccountInput(resolveSignalAccount({ cfg, accountId }).config.account) ?? + undefined, + keepPrompt: (value) => `Signal account set (${value}). Keep it?`, + validate: ({ value }) => + normalizeSignalAccountInput(value) ? undefined : INVALID_SIGNAL_ACCOUNT_ERROR, + normalizeValue: ({ value }) => normalizeSignalAccountInput(value) ?? value, + }, + ], + completionNote: { + title: "Signal next steps", + lines: [ + 'Link device with: signal-cli link -n "OpenClaw"', + "Scan QR in Signal -> Linked Devices", + `Then run: ${formatCliCommand("openclaw gateway call channels.status --params '{\"probe\":true}'")}`, + `Docs: ${formatDocsLink("/signal", "signal")}`, + ], + }, + dmPolicy: signalDmPolicy, + disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false), + } satisfies ChannelSetupWizard; +} diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index 6a7b7604450..51dbbd5625a 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -5,89 +5,29 @@ import { setChannelDmPolicyWithAllowFrom, setOnboardingChannelEnabled, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import { formatCliCommand } from "../../../src/cli/command-format.js"; import { detectBinary } from "../../../src/commands/onboard-helpers.js"; import { installSignalCli } from "../../../src/commands/signal-install.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; -import { normalizeE164 } from "../../../src/utils.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { listSignalAccountIds, resolveDefaultSignalAccountId, resolveSignalAccount, } from "./accounts.js"; +import { + normalizeSignalAccountInput, + parseSignalAllowFromEntries, + signalSetupAdapter, +} from "./setup-core.js"; const channel = "signal" as const; -const MIN_E164_DIGITS = 5; -const MAX_E164_DIGITS = 15; -const DIGITS_ONLY = /^\d+$/; const INVALID_SIGNAL_ACCOUNT_ERROR = "Invalid E.164 phone number (must start with + and country code, e.g. +15555550123)"; -export function normalizeSignalAccountInput(value: string | null | undefined): string | null { - const trimmed = value?.trim(); - if (!trimmed) { - return null; - } - const normalized = normalizeE164(trimmed); - const digits = normalized.slice(1); - if (!DIGITS_ONLY.test(digits)) { - return null; - } - if (digits.length < MIN_E164_DIGITS || digits.length > MAX_E164_DIGITS) { - return null; - } - return `+${digits}`; -} - -function isUuidLike(value: string): boolean { - return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); -} - -export function parseSignalAllowFromEntries(raw: string): { entries: string[]; error?: string } { - return parseOnboardingEntriesAllowingWildcard(raw, (entry) => { - if (entry.toLowerCase().startsWith("uuid:")) { - const id = entry.slice("uuid:".length).trim(); - if (!id) { - return { error: "Invalid uuid entry" }; - } - return { value: `uuid:${id}` }; - } - if (isUuidLike(entry)) { - return { value: `uuid:${entry}` }; - } - const normalized = normalizeSignalAccountInput(entry); - if (!normalized) { - return { error: `Invalid entry: ${entry}` }; - } - return { value: normalized }; - }); -} - -function buildSignalSetupPatch(input: { - signalNumber?: string; - cliPath?: string; - httpUrl?: string; - httpHost?: string; - httpPort?: string; -}) { - return { - ...(input.signalNumber ? { account: input.signalNumber } : {}), - ...(input.cliPath ? { cliPath: input.cliPath } : {}), - ...(input.httpUrl ? { httpUrl: input.httpUrl } : {}), - ...(input.httpHost ? { httpHost: input.httpHost } : {}), - ...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}), - }; -} - async function promptSignalAllowFrom(params: { cfg: OpenClawConfig; prompter: WizardPrompter; @@ -131,75 +71,6 @@ const signalDmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptSignalAllowFrom, }; -export const signalSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ input }) => { - if ( - !input.signalNumber && - !input.httpUrl && - !input.httpHost && - !input.httpPort && - !input.cliPath - ) { - return "Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - signal: { - ...next.channels?.signal, - enabled: true, - ...buildSignalSetupPatch(input), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - signal: { - ...next.channels?.signal, - enabled: true, - accounts: { - ...next.channels?.signal?.accounts, - [accountId]: { - ...next.channels?.signal?.accounts?.[accountId], - enabled: true, - ...buildSignalSetupPatch(input), - }, - }, - }, - }, - }; - }, -}; - export const signalSetupWizard: ChannelSetupWizard = { channel, status: { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index a6044a0da84..04d03c56f8e 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -782,10 +782,8 @@ export { resolveSignalAccount, type ResolvedSignalAccount, } from "../../extensions/signal/src/accounts.js"; -export { - signalSetupAdapter, - signalSetupWizard, -} from "../../extensions/signal/src/setup-surface.js"; +export { signalSetupWizard } from "../../extensions/signal/src/setup-surface.js"; +export { signalSetupAdapter } from "../../extensions/signal/src/setup-core.js"; export { looksLikeSignalTargetId, normalizeSignalMessagingTarget, diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index 2eb0497c277..f57a046ab03 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -16,10 +16,8 @@ export { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, } from "../config/runtime-group-policy.js"; -export { - signalSetupAdapter, - signalSetupWizard, -} from "../../extensions/signal/src/setup-surface.js"; +export { signalSetupWizard } from "../../extensions/signal/src/setup-surface.js"; +export { signalSetupAdapter } from "../../extensions/signal/src/setup-core.js"; export { SignalConfigSchema } from "../config/zod-schema.providers-core.js"; export { normalizeE164 } from "../utils.js"; From fb991e6f3156aeb6bbf3f15e27926cb9e2697572 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 18:46:22 -0700 Subject: [PATCH 102/943] perf(plugins): lazy-load setup surfaces --- docs/tools/plugin.md | 10 +- extensions/bluebubbles/package.json | 1 + extensions/bluebubbles/setup-entry.ts | 5 + extensions/feishu/package.json | 1 + extensions/feishu/setup-entry.ts | 5 + extensions/googlechat/package.json | 1 + extensions/googlechat/setup-entry.ts | 6 + extensions/irc/package.json | 3 +- extensions/irc/setup-entry.ts | 5 + extensions/matrix/package.json | 1 + extensions/matrix/setup-entry.ts | 5 + extensions/msteams/package.json | 1 + extensions/msteams/setup-entry.ts | 5 + extensions/nextcloud-talk/package.json | 1 + extensions/nextcloud-talk/setup-entry.ts | 5 + extensions/tlon/package.json | 1 + extensions/tlon/setup-entry.ts | 5 + scripts/copy-bundled-plugin-metadata.mjs | 12 ++ src/cli/program/preaction.test.ts | 27 +++ src/cli/program/preaction.ts | 12 +- src/commands/channels/add.ts | 5 +- src/commands/onboard-channels.ts | 45 ++++- .../onboarding/plugin-install.test.ts | 4 + src/commands/onboarding/plugin-install.ts | 1 + src/plugins/discovery.ts | 29 +++ src/plugins/loader.test.ts | 182 ++++++++++++++++++ src/plugins/loader.ts | 68 ++++++- src/plugins/manifest-registry.ts | 2 + src/plugins/manifest.ts | 1 + tsdown.config.ts | 10 +- 30 files changed, 443 insertions(+), 16 deletions(-) create mode 100644 extensions/bluebubbles/setup-entry.ts create mode 100644 extensions/feishu/setup-entry.ts create mode 100644 extensions/googlechat/setup-entry.ts create mode 100644 extensions/irc/setup-entry.ts create mode 100644 extensions/matrix/setup-entry.ts create mode 100644 extensions/msteams/setup-entry.ts create mode 100644 extensions/nextcloud-talk/setup-entry.ts create mode 100644 extensions/tlon/setup-entry.ts diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 4113c9fbd05..2a5b5d37006 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -749,7 +749,8 @@ A plugin directory may include a `package.json` with `openclaw.extensions`: { "name": "my-pack", "openclaw": { - "extensions": ["./src/safety.ts", "./src/tools.ts"] + "extensions": ["./src/safety.ts", "./src/tools.ts"], + "setupEntry": "./src/setup-entry.ts" } } ``` @@ -768,6 +769,12 @@ Security note: `openclaw plugins install` installs plugin dependencies with `npm install --ignore-scripts` (no lifecycle scripts). Keep plugin dependency trees "pure JS/TS" and avoid packages that require `postinstall` builds. +Optional: `openclaw.setupEntry` can point at a lightweight setup-only module. +When OpenClaw needs onboarding/setup surfaces for a disabled channel plugin, it +loads `setupEntry` instead of the full plugin entry. This keeps startup and +onboarding lighter when your main plugin entry also wires tools, hooks, or +other runtime-only code. + ### Channel catalog metadata Channel plugins can advertise onboarding metadata via `openclaw.channel` and @@ -1657,6 +1664,7 @@ Recommended packaging: Publishing contract: - Plugin `package.json` must include `openclaw.extensions` with one or more entry files. +- Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled channel onboarding/setup. - Entry files can be `.js` or `.ts` (jiti loads TS at runtime). - `openclaw plugins install ` uses `npm pack`, extracts into `~/.openclaw/extensions//`, and enables it in config. - Config key stability: scoped packages are normalized to the **unscoped** id for `plugins.entries.*`. diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index 67df516b8d7..2426958d346 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -10,6 +10,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "bluebubbles", "label": "BlueBubbles", diff --git a/extensions/bluebubbles/setup-entry.ts b/extensions/bluebubbles/setup-entry.ts new file mode 100644 index 00000000000..5e05d9c8bb2 --- /dev/null +++ b/extensions/bluebubbles/setup-entry.ts @@ -0,0 +1,5 @@ +import { bluebubblesPlugin } from "./src/channel.js"; + +export default { + plugin: bluebubblesPlugin, +}; diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 805dd389b0a..d5dfe64f369 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -13,6 +13,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "feishu", "label": "Feishu", diff --git a/extensions/feishu/setup-entry.ts b/extensions/feishu/setup-entry.ts new file mode 100644 index 00000000000..3e4df4faee8 --- /dev/null +++ b/extensions/feishu/setup-entry.ts @@ -0,0 +1,5 @@ +import { feishuPlugin } from "./src/channel.js"; + +export default { + plugin: feishuPlugin, +}; diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 3514ac52b90..2c4469163db 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -19,6 +19,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "googlechat", "label": "Google Chat", diff --git a/extensions/googlechat/setup-entry.ts b/extensions/googlechat/setup-entry.ts new file mode 100644 index 00000000000..7d80304ccf3 --- /dev/null +++ b/extensions/googlechat/setup-entry.ts @@ -0,0 +1,6 @@ +import { googlechatDock, googlechatPlugin } from "./src/channel.js"; + +export default { + plugin: googlechatPlugin, + dock: googlechatDock, +}; diff --git a/extensions/irc/package.json b/extensions/irc/package.json index 8d162b9ac20..774fa993dbd 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -9,6 +9,7 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "setupEntry": "./setup-entry.ts" } } diff --git a/extensions/irc/setup-entry.ts b/extensions/irc/setup-entry.ts new file mode 100644 index 00000000000..fe8bea1814d --- /dev/null +++ b/extensions/irc/setup-entry.ts @@ -0,0 +1,5 @@ +import { ircPlugin } from "./src/channel.js"; + +export default { + plugin: ircPlugin, +}; diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 5b973b88635..bebd410fae9 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -15,6 +15,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "matrix", "label": "Matrix", diff --git a/extensions/matrix/setup-entry.ts b/extensions/matrix/setup-entry.ts new file mode 100644 index 00000000000..4cbabfe6333 --- /dev/null +++ b/extensions/matrix/setup-entry.ts @@ -0,0 +1,5 @@ +import { matrixPlugin } from "./src/channel.js"; + +export default { + plugin: matrixPlugin, +}; diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 4784334d1d5..eb02c9cee13 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -11,6 +11,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "msteams", "label": "Microsoft Teams", diff --git a/extensions/msteams/setup-entry.ts b/extensions/msteams/setup-entry.ts new file mode 100644 index 00000000000..fb850b60e18 --- /dev/null +++ b/extensions/msteams/setup-entry.ts @@ -0,0 +1,5 @@ +import { msteamsPlugin } from "./src/channel.js"; + +export default { + plugin: msteamsPlugin, +}; diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index c217d0f0ce7..d594a67b96f 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -10,6 +10,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "nextcloud-talk", "label": "Nextcloud Talk", diff --git a/extensions/nextcloud-talk/setup-entry.ts b/extensions/nextcloud-talk/setup-entry.ts new file mode 100644 index 00000000000..f33df37c7dc --- /dev/null +++ b/extensions/nextcloud-talk/setup-entry.ts @@ -0,0 +1,5 @@ +import { nextcloudTalkPlugin } from "./src/channel.js"; + +export default { + plugin: nextcloudTalkPlugin, +}; diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index 40ec9aeedde..071280374a3 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -13,6 +13,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "tlon", "label": "Tlon", diff --git a/extensions/tlon/setup-entry.ts b/extensions/tlon/setup-entry.ts new file mode 100644 index 00000000000..667e917c8da --- /dev/null +++ b/extensions/tlon/setup-entry.ts @@ -0,0 +1,5 @@ +import { tlonPlugin } from "./src/channel.js"; + +export default { + plugin: tlonPlugin, +}; diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs index 426f319c02c..f5eac7ba513 100644 --- a/scripts/copy-bundled-plugin-metadata.mjs +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -23,6 +23,15 @@ export function rewritePackageExtensions(entries) { }); } +function rewritePackageEntry(entry) { + if (typeof entry !== "string" || entry.trim().length === 0) { + return undefined; + } + const normalized = entry.replace(/^\.\//, ""); + const rewritten = normalized.replace(/\.[^.]+$/u, ".js"); + return `./${rewritten}`; +} + function ensurePathInsideRoot(rootDir, rawPath) { const resolved = path.resolve(rootDir, rawPath); const relative = path.relative(rootDir, resolved); @@ -176,6 +185,9 @@ export function copyBundledPluginMetadata(params = {}) { packageJson.openclaw = { ...packageJson.openclaw, extensions: rewritePackageExtensions(packageJson.openclaw.extensions), + ...(typeof packageJson.openclaw.setupEntry === "string" + ? { setupEntry: rewritePackageEntry(packageJson.openclaw.setupEntry) } + : {}), }; } diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index 2376e97100f..7b8fe8b878a 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -91,6 +91,8 @@ describe("registerPreActionHooks", () => { program.command("agents").action(() => {}); program.command("configure").action(() => {}); program.command("onboard").action(() => {}); + const channels = program.command("channels"); + channels.command("add").action(() => {}); program .command("update") .command("status") @@ -167,6 +169,31 @@ describe("registerPreActionHooks", () => { expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "all" }); }); + it("keeps onboarding and channels add manifest-first", async () => { + await runPreAction({ + parseArgv: ["onboard"], + processArgv: ["node", "openclaw", "onboard"], + }); + + expect(ensureConfigReadyMock).toHaveBeenCalledWith({ + runtime: runtimeMock, + commandPath: ["onboard"], + }); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); + + vi.clearAllMocks(); + await runPreAction({ + parseArgv: ["channels", "add"], + processArgv: ["node", "openclaw", "channels", "add"], + }); + + expect(ensureConfigReadyMock).toHaveBeenCalledWith({ + runtime: runtimeMock, + commandPath: ["channels", "add"], + }); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); + }); + it("skips help/version preaction and respects banner opt-out", async () => { await runPreAction({ parseArgv: ["status"], diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 19659f97c7e..edeec669079 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -32,7 +32,6 @@ const PLUGIN_REQUIRED_COMMANDS = new Set([ "directory", "agents", "configure", - "onboard", "status", "health", ]); @@ -72,15 +71,19 @@ function resolvePluginRegistryScope(commandPath: string[]): "channels" | "all" { } function shouldLoadPluginsForCommand(commandPath: string[], argv: string[]): boolean { - if (!PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) { + const [primary, secondary] = commandPath; + if (!primary || !PLUGIN_REQUIRED_COMMANDS.has(primary)) { return false; } - if ((commandPath[0] === "status" || commandPath[0] === "health") && hasFlag(argv, "--json")) { + if ((primary === "status" || primary === "health") && hasFlag(argv, "--json")) { + return false; + } + // Onboarding/setup should stay manifest-first and load selected plugins on demand. + if (primary === "onboard" || (primary === "channels" && secondary === "add")) { return false; } return true; } - function getRootCommand(command: Command): Command { let current = command; while (current.parent) { @@ -148,6 +151,7 @@ export function registerPreActionHooks(program: Command, programVersion: string) ...(suppressDoctorStdout ? { suppressDoctorStdout: true } : {}), }); // Load plugins for commands that need channel access + if (shouldLoadPluginsForCommand(commandPath, argv)) { if (shouldLoadPluginsForCommand(commandPath, argv)) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); ensurePluginRegistryLoaded({ scope: resolvePluginRegistryScope(commandPath) }); diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index e412c60215a..88e1a245906 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -195,7 +195,10 @@ export async function channelsAddCommand( ...(pluginId ? { pluginId } : {}), workspaceDir: resolveWorkspaceDir(), }); - return snapshot.channels.find((entry) => entry.plugin.id === channelId)?.plugin; + return ( + snapshot.channels.find((entry) => entry.plugin.id === channelId)?.plugin ?? + snapshot.channelSetups.find((entry) => entry.plugin.id === channelId)?.plugin + ); }; if (!channel && catalogEntry) { diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 81deb95e901..cdb987914bc 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -17,6 +17,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { isChannelConfigured } from "../config/plugin-auto-enable.js"; import type { DmPolicy } from "../config/types.js"; import { enablePluginInConfig } from "../plugins/enable.js"; +import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; @@ -123,11 +124,16 @@ async function collectChannelStatus(params: { installedPlugins?: ReturnType; }): Promise { const installedPlugins = params.installedPlugins ?? listChannelSetupPlugins(); - const installedIds = new Set(installedPlugins.map((plugin) => plugin.id)); const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); - const catalogEntries = listChannelPluginCatalogEntries({ workspaceDir }).filter( - (entry) => !installedIds.has(entry.id), + const allCatalogEntries = listChannelPluginCatalogEntries({ workspaceDir }); + const installedChannelIds = new Set( + loadPluginManifestRegistry({ + config: params.cfg, + workspaceDir, + env: process.env, + }).plugins.flatMap((plugin) => plugin.channels), ); + const catalogEntries = allCatalogEntries.filter((entry) => !installedChannelIds.has(entry.id)); const statusEntries = await Promise.all( listChannelOnboardingAdapters().map((adapter) => adapter.getStatus({ @@ -151,6 +157,28 @@ async function collectChannelStatus(params: { quickstartScore: 0, }; }); + const discoveredPluginStatuses = allCatalogEntries + .filter((entry) => installedChannelIds.has(entry.id)) + .filter((entry) => !statusByChannel.has(entry.id as ChannelChoice)) + .map((entry) => { + const configured = isChannelConfigured(params.cfg, entry.id); + const pluginEnabled = + params.cfg.plugins?.entries?.[entry.pluginId ?? entry.id]?.enabled !== false; + const statusLabel = configured + ? pluginEnabled + ? "configured" + : "configured (plugin disabled)" + : pluginEnabled + ? "installed" + : "installed (plugin disabled)"; + return { + channel: entry.id as ChannelChoice, + configured, + statusLines: [`${entry.meta.label}: ${statusLabel}`], + selectionHint: statusLabel, + quickstartScore: 0, + }; + }); const catalogStatuses = catalogEntries.map((entry) => ({ channel: entry.id, configured: false, @@ -158,7 +186,12 @@ async function collectChannelStatus(params: { selectionHint: "plugin · install", quickstartScore: 0, })); - const combinedStatuses = [...statusEntries, ...fallbackStatuses, ...catalogStatuses]; + const combinedStatuses = [ + ...statusEntries, + ...fallbackStatuses, + ...discoveredPluginStatuses, + ...catalogStatuses, + ]; const mergedStatusByChannel = new Map(combinedStatuses.map((entry) => [entry.channel, entry])); const statusLines = combinedStatuses.flatMap((entry) => entry.statusLines); return { @@ -344,7 +377,9 @@ export async function setupChannels( ...(pluginId ? { pluginId } : {}), workspaceDir: resolveWorkspaceDir(), }); - const plugin = snapshot.channels.find((entry) => entry.plugin.id === channel)?.plugin; + const plugin = + snapshot.channels.find((entry) => entry.plugin.id === channel)?.plugin ?? + snapshot.channelSetups.find((entry) => entry.plugin.id === channel)?.plugin; if (plugin) { rememberScopedPlugin(plugin); } diff --git a/src/commands/onboarding/plugin-install.test.ts b/src/commands/onboarding/plugin-install.test.ts index 1cd9e530b86..953fccf5a68 100644 --- a/src/commands/onboarding/plugin-install.test.ts +++ b/src/commands/onboarding/plugin-install.test.ts @@ -292,6 +292,7 @@ describe("ensureOnboardingPluginInstalled", () => { config: cfg, workspaceDir: "/tmp/openclaw-workspace", cache: false, + includeSetupOnlyChannelPlugins: true, }), ); expect(clearPluginDiscoveryCache.mock.invocationCallOrder[0]).toBeLessThan( @@ -316,6 +317,7 @@ describe("ensureOnboardingPluginInstalled", () => { workspaceDir: "/tmp/openclaw-workspace", cache: false, onlyPluginIds: ["telegram"], + includeSetupOnlyChannelPlugins: true, }), ); }); @@ -377,6 +379,7 @@ describe("ensureOnboardingPluginInstalled", () => { workspaceDir: "/tmp/openclaw-workspace", cache: false, onlyPluginIds: ["telegram"], + includeSetupOnlyChannelPlugins: true, activate: false, }), ); @@ -400,6 +403,7 @@ describe("ensureOnboardingPluginInstalled", () => { workspaceDir: "/tmp/openclaw-workspace", cache: false, onlyPluginIds: ["@openclaw/msteams-plugin"], + includeSetupOnlyChannelPlugins: true, activate: false, }), ); diff --git a/src/commands/onboarding/plugin-install.ts b/src/commands/onboarding/plugin-install.ts index 31f5ec1d64d..3a7f5623425 100644 --- a/src/commands/onboarding/plugin-install.ts +++ b/src/commands/onboarding/plugin-install.ts @@ -250,6 +250,7 @@ function loadOnboardingPluginRegistry(params: { cache: false, logger: createPluginLoaderLogger(log), onlyPluginIds: params.onlyPluginIds, + includeSetupOnlyChannelPlugins: true, activate: params.activate, }); } diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index c102ffc80c7..743b0b569f9 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -19,6 +19,7 @@ const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]); export type PluginCandidate = { idHint: string; source: string; + setupSource?: string; rootDir: string; origin: PluginOrigin; format?: PluginFormat; @@ -355,6 +356,7 @@ function addCandidate(params: { seen: Set; idHint: string; source: string; + setupSource?: string; rootDir: string; origin: PluginOrigin; format?: PluginFormat; @@ -385,6 +387,7 @@ function addCandidate(params: { params.candidates.push({ idHint: params.idHint, source: resolved, + setupSource: params.setupSource, rootDir: resolvedRoot, origin: params.origin, format: params.format ?? "openclaw", @@ -520,6 +523,17 @@ function discoverInDirectory(params: { const manifest = readPackageManifest(fullPath, rejectHardlinks); const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined); const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : []; + const setupEntryPath = getPackageManifestMetadata(manifest ?? undefined)?.setupEntry; + const setupSource = + typeof setupEntryPath === "string" && setupEntryPath.trim().length > 0 + ? resolvePackageEntrySource({ + packageDir: fullPath, + entryPath: setupEntryPath, + sourceLabel: fullPath, + diagnostics: params.diagnostics, + rejectHardlinks, + }) + : null; if (extensions.length > 0) { for (const extPath of extensions) { @@ -543,6 +557,7 @@ function discoverInDirectory(params: { hasMultipleExtensions: extensions.length > 1, }), source: resolved, + ...(setupSource ? { setupSource } : {}), rootDir: fullPath, origin: params.origin, ownershipUid: params.ownershipUid, @@ -577,6 +592,7 @@ function discoverInDirectory(params: { seen: params.seen, idHint: entry.name, source: indexFile, + ...(setupSource ? { setupSource } : {}), rootDir: fullPath, origin: params.origin, ownershipUid: params.ownershipUid, @@ -637,6 +653,17 @@ function discoverFromPath(params: { const manifest = readPackageManifest(resolved, rejectHardlinks); const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined); const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : []; + const setupEntryPath = getPackageManifestMetadata(manifest ?? undefined)?.setupEntry; + const setupSource = + typeof setupEntryPath === "string" && setupEntryPath.trim().length > 0 + ? resolvePackageEntrySource({ + packageDir: resolved, + entryPath: setupEntryPath, + sourceLabel: resolved, + diagnostics: params.diagnostics, + rejectHardlinks, + }) + : null; if (extensions.length > 0) { for (const extPath of extensions) { @@ -660,6 +687,7 @@ function discoverFromPath(params: { hasMultipleExtensions: extensions.length > 1, }), source, + ...(setupSource ? { setupSource } : {}), rootDir: resolved, origin: params.origin, ownershipUid: params.ownershipUid, @@ -695,6 +723,7 @@ function discoverFromPath(params: { seen: params.seen, idHint: path.basename(resolved), source: indexFile, + ...(setupSource ? { setupSource } : {}), rootDir: resolved, origin: params.origin, ownershipUid: params.ownershipUid, diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 0460e481b25..fb6805667cb 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1703,6 +1703,188 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s expect(disabled?.status).toBe("disabled"); }); + it("skips disabled channel imports unless setup-only loading is explicitly enabled", () => { + useNoBundledPlugins(); + const marker = path.join(makeTempDir(), "lazy-channel-imported.txt"); + const plugin = writePlugin({ + id: "lazy-channel", + filename: "lazy-channel.cjs", + body: `require("node:fs").writeFileSync(${JSON.stringify(marker)}, "loaded", "utf-8"); +module.exports = { + id: "lazy-channel", + register(api) { + api.registerChannel({ + plugin: { + id: "lazy-channel", + meta: { + id: "lazy-channel", + label: "Lazy Channel", + selectionLabel: "Lazy Channel", + docsPath: "/channels/lazy-channel", + blurb: "lazy test channel", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, + }); + }, +};`, + }); + fs.writeFileSync( + path.join(plugin.dir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "lazy-channel", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["lazy-channel"], + }, + null, + 2, + ), + "utf-8", + ); + const config = { + plugins: { + load: { paths: [plugin.file] }, + allow: ["lazy-channel"], + entries: { + "lazy-channel": { enabled: false }, + }, + }, + }; + + const registry = loadOpenClawPlugins({ + cache: false, + config, + }); + + expect(fs.existsSync(marker)).toBe(false); + expect(registry.channelSetups).toHaveLength(0); + expect(registry.plugins.find((entry) => entry.id === "lazy-channel")?.status).toBe("disabled"); + + const setupRegistry = loadOpenClawPlugins({ + cache: false, + config, + includeSetupOnlyChannelPlugins: true, + }); + + expect(fs.existsSync(marker)).toBe(true); + expect(setupRegistry.channelSetups).toHaveLength(1); + expect(setupRegistry.channels).toHaveLength(0); + expect(setupRegistry.plugins.find((entry) => entry.id === "lazy-channel")?.status).toBe( + "disabled", + ); + }); + + it("uses package setupEntry for setup-only channel loads", () => { + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + const fullMarker = path.join(pluginDir, "full-loaded.txt"); + const setupMarker = path.join(pluginDir, "setup-loaded.txt"); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/setup-entry-test", + openclaw: { + extensions: ["./index.cjs"], + setupEntry: "./setup-entry.cjs", + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "setup-entry-test", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["setup-entry-test"], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + id: "setup-entry-test", + register(api) { + api.registerChannel({ + plugin: { + id: "setup-entry-test", + meta: { + id: "setup-entry-test", + label: "Setup Entry Test", + selectionLabel: "Setup Entry Test", + docsPath: "/channels/setup-entry-test", + blurb: "full entry should not run in setup-only mode", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, + }); + }, +};`, + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); +module.exports = { + plugin: { + id: "setup-entry-test", + meta: { + id: "setup-entry-test", + label: "Setup Entry Test", + selectionLabel: "Setup Entry Test", + docsPath: "/channels/setup-entry-test", + blurb: "setup entry", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, +};`, + "utf-8", + ); + + const setupRegistry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-entry-test"], + entries: { + "setup-entry-test": { enabled: false }, + }, + }, + }, + includeSetupOnlyChannelPlugins: true, + }); + + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + expect(setupRegistry.channelSetups).toHaveLength(1); + expect(setupRegistry.channels).toHaveLength(0); + }); + it("blocks before_prompt_build but preserves legacy model overrides when prompt injection is disabled", async () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 13f6842d1e1..40fd3e36cfd 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -2,6 +2,8 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createJiti } from "jiti"; +import type { ChannelDock } from "../channels/dock.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; @@ -51,6 +53,7 @@ export type PluginLoadOptions = { cache?: boolean; mode?: "full" | "validate"; onlyPluginIds?: string[]; + includeSetupOnlyChannelPlugins?: boolean; activate?: boolean; }; @@ -244,6 +247,7 @@ function buildCacheKey(params: { installs?: Record; env: NodeJS.ProcessEnv; onlyPluginIds?: string[]; + includeSetupOnlyChannelPlugins?: boolean; }): string { const { roots, loadPaths } = resolvePluginCacheInputs({ workspaceDir: params.workspaceDir, @@ -267,11 +271,12 @@ function buildCacheKey(params: { ]), ); const scopeKey = JSON.stringify(params.onlyPluginIds ?? []); + const setupOnlyKey = params.includeSetupOnlyChannelPlugins === true ? "setup-only" : "runtime"; return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({ ...params.plugins, installs, loadPaths, - })}::${scopeKey}`; + })}::${scopeKey}::${setupOnlyKey}`; } function normalizeScopedPluginIds(ids?: string[]): string[] | undefined { @@ -326,6 +331,32 @@ function resolvePluginModuleExport(moduleExport: unknown): { return {}; } +function resolveSetupChannelRegistration(moduleExport: unknown): { + plugin?: ChannelPlugin; + dock?: ChannelDock; +} { + const resolved = + moduleExport && + typeof moduleExport === "object" && + "default" in (moduleExport as Record) + ? (moduleExport as { default: unknown }).default + : moduleExport; + if (!resolved || typeof resolved !== "object") { + return {}; + } + const setup = resolved as { + plugin?: unknown; + dock?: unknown; + }; + if (!setup.plugin || typeof setup.plugin !== "object") { + return {}; + } + return { + plugin: setup.plugin as ChannelPlugin, + ...(setup.dock && typeof setup.dock === "object" ? { dock: setup.dock as ChannelDock } : {}), + }; +} + function createPluginRecord(params: { id: string; name?: string; @@ -669,6 +700,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const normalized = normalizePluginsConfig(cfg.plugins); const onlyPluginIds = normalizeScopedPluginIds(options.onlyPluginIds); const onlyPluginIdSet = onlyPluginIds ? new Set(onlyPluginIds) : null; + const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true; const shouldActivate = options.activate !== false; // NOTE: `activate` is intentionally excluded from the cache key. All non-activating // (snapshot) callers pass `cache: false` via loadOnboardingPluginRegistry(), so they @@ -680,6 +712,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi installs: cfg.plugins?.installs, env, onlyPluginIds, + includeSetupOnlyChannelPlugins, }); const cacheEnabled = options.cache !== false; if (cacheEnabled) { @@ -892,7 +925,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const registrationMode = enableState.enabled ? "full" - : !validateOnly && manifestRecord.channels.length > 0 + : includeSetupOnlyChannelPlugins && !validateOnly && manifestRecord.channels.length > 0 ? "setup-only" : null; @@ -960,8 +993,12 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } const pluginRoot = safeRealpathOrResolve(candidate.rootDir); + const loadSource = + registrationMode === "setup-only" && manifestRecord.setupSource + ? manifestRecord.setupSource + : candidate.source; const opened = openBoundaryFileSync({ - absolutePath: candidate.source, + absolutePath: loadSource, rootPath: pluginRoot, boundaryLabel: "plugin root", rejectHardlinks: candidate.origin !== "bundled", @@ -992,6 +1029,31 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } + if (registrationMode === "setup-only" && manifestRecord.setupSource) { + const setupRegistration = resolveSetupChannelRegistration(mod); + if (setupRegistration.plugin) { + if (setupRegistration.plugin.id && setupRegistration.plugin.id !== record.id) { + pushPluginLoadError( + `plugin id mismatch (config uses "${record.id}", setup export uses "${setupRegistration.plugin.id}")`, + ); + continue; + } + const api = createApi(record, { + config: cfg, + pluginConfig: {}, + hookPolicy: entry?.hooks, + registrationMode, + }); + api.registerChannel({ + plugin: setupRegistration.plugin, + ...(setupRegistration.dock ? { dock: setupRegistration.dock } : {}), + }); + registry.plugins.push(record); + seenIds.set(pluginId, candidate.origin); + continue; + } + } + const resolved = resolvePluginModuleExport(mod); const definition = resolved.definition; const register = resolved.register; diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 48fdae50d95..2c24b87f541 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -48,6 +48,7 @@ export type PluginManifestRecord = { workspaceDir?: string; rootDir: string; source: string; + setupSource?: string; manifestPath: string; schemaCacheKey?: string; configSchema?: Record; @@ -158,6 +159,7 @@ function buildRecord(params: { workspaceDir: params.candidate.workspaceDir, rootDir: params.candidate.rootDir, source: params.candidate.source, + setupSource: params.candidate.setupSource, manifestPath: params.manifestPath, schemaCacheKey: params.schemaCacheKey, configSchema: params.configSchema, diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 3a3abe0a620..0cbdd9264f3 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -148,6 +148,7 @@ export type PluginPackageInstall = { export type OpenClawPackageManifest = { extensions?: string[]; + setupEntry?: string; channel?: PluginPackageChannel; install?: PluginPackageInstall; }; diff --git a/tsdown.config.ts b/tsdown.config.ts index 2b7c9dbe192..b266f660421 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -124,13 +124,21 @@ function listBundledPluginBuildEntries(): Record { if (fs.existsSync(packageJsonPath)) { try { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { - openclaw?: { extensions?: unknown }; + openclaw?: { extensions?: unknown; setupEntry?: unknown }; }; packageEntries = Array.isArray(packageJson.openclaw?.extensions) ? packageJson.openclaw.extensions.filter( (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, ) : []; + const setupEntry = + typeof packageJson.openclaw?.setupEntry === "string" && + packageJson.openclaw.setupEntry.trim().length > 0 + ? packageJson.openclaw.setupEntry + : undefined; + if (setupEntry) { + packageEntries = Array.from(new Set([...packageEntries, setupEntry])); + } } catch { packageEntries = []; } From 57a0534f937e9fbdc69e9804d859f5651b6f2dbc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 18:47:23 -0700 Subject: [PATCH 103/943] fix(cli): repair preaction merge typo --- src/cli/program/preaction.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index edeec669079..6e869a23215 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -151,7 +151,6 @@ export function registerPreActionHooks(program: Command, programVersion: string) ...(suppressDoctorStdout ? { suppressDoctorStdout: true } : {}), }); // Load plugins for commands that need channel access - if (shouldLoadPluginsForCommand(commandPath, argv)) { if (shouldLoadPluginsForCommand(commandPath, argv)) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); ensurePluginRegistryLoaded({ scope: resolvePluginRegistryScope(commandPath) }); From 399b6f745a508e46911079aba187f318c9e08166 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 18:51:38 -0700 Subject: [PATCH 104/943] Signal: restore setup surface helper exports --- extensions/signal/src/setup-surface.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index 51dbbd5625a..822df4caf10 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -181,3 +181,5 @@ export const signalSetupWizard: ChannelSetupWizard = { dmPolicy: signalDmPolicy, disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), }; + +export { normalizeSignalAccountInput, parseSignalAllowFromEntries, signalSetupAdapter }; From 413d2ff3da8e4dd7c2b12845a0f12f710f89c8f4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 18:52:22 -0700 Subject: [PATCH 105/943] iMessage: lazy-load setup wizard surface --- extensions/imessage/src/channel.runtime.ts | 1 + extensions/imessage/src/channel.ts | 10 +- extensions/imessage/src/setup-core.ts | 236 +++++++++++++++++++++ extensions/imessage/src/setup-surface.ts | 111 +--------- src/plugin-sdk/imessage.ts | 6 +- src/plugin-sdk/index.ts | 6 +- 6 files changed, 254 insertions(+), 116 deletions(-) create mode 100644 extensions/imessage/src/channel.runtime.ts create mode 100644 extensions/imessage/src/setup-core.ts diff --git a/extensions/imessage/src/channel.runtime.ts b/extensions/imessage/src/channel.runtime.ts new file mode 100644 index 00000000000..81229e49ff9 --- /dev/null +++ b/extensions/imessage/src/channel.runtime.ts @@ -0,0 +1 @@ +export { imessageSetupWizard } from "./setup-surface.js"; diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 5760d1c2fb3..f2621dea5c2 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -28,10 +28,18 @@ import { import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { getIMessageRuntime } from "./runtime.js"; -import { imessageSetupAdapter, imessageSetupWizard } from "./setup-surface.js"; +import { createIMessageSetupWizardProxy, imessageSetupAdapter } from "./setup-core.js"; const meta = getChatChannelMeta("imessage"); +async function loadIMessageChannelRuntime() { + return await import("./channel.runtime.js"); +} + +const imessageSetupWizard = createIMessageSetupWizardProxy(async () => ({ + imessageSetupWizard: (await loadIMessageChannelRuntime()).imessageSetupWizard, +})); + type IMessageSendFn = ReturnType< typeof getIMessageRuntime >["channel"]["imessage"]["sendMessageIMessage"]; diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts new file mode 100644 index 00000000000..69a8072bd59 --- /dev/null +++ b/extensions/imessage/src/setup-core.ts @@ -0,0 +1,236 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + parseOnboardingEntriesAllowingWildcard, + promptParsedAllowFromForScopedChannel, + setChannelDmPolicyWithAllowFrom, + setOnboardingChannelEnabled, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { + listIMessageAccountIds, + resolveDefaultIMessageAccountId, + resolveIMessageAccount, +} from "./accounts.js"; +import { normalizeIMessageHandle } from "./targets.js"; + +const channel = "imessage" as const; + +export function parseIMessageAllowFromEntries(raw: string): { entries: string[]; error?: string } { + return parseOnboardingEntriesAllowingWildcard(raw, (entry) => { + const lower = entry.toLowerCase(); + if (lower.startsWith("chat_id:")) { + const id = entry.slice("chat_id:".length).trim(); + if (!/^\d+$/.test(id)) { + return { error: `Invalid chat_id: ${entry}` }; + } + return { value: entry }; + } + if (lower.startsWith("chat_guid:")) { + if (!entry.slice("chat_guid:".length).trim()) { + return { error: "Invalid chat_guid entry" }; + } + return { value: entry }; + } + if (lower.startsWith("chat_identifier:")) { + if (!entry.slice("chat_identifier:".length).trim()) { + return { error: "Invalid chat_identifier entry" }; + } + return { value: entry }; + } + if (!normalizeIMessageHandle(entry)) { + return { error: `Invalid handle: ${entry}` }; + } + return { value: entry }; + }); +} + +function buildIMessageSetupPatch(input: { + cliPath?: string; + dbPath?: string; + service?: "imessage" | "sms" | "auto"; + region?: string; +}) { + return { + ...(input.cliPath ? { cliPath: input.cliPath } : {}), + ...(input.dbPath ? { dbPath: input.dbPath } : {}), + ...(input.service ? { service: input.service } : {}), + ...(input.region ? { region: input.region } : {}), + }; +} + +async function promptIMessageAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + return promptParsedAllowFromForScopedChannel({ + cfg: params.cfg, + channel, + accountId: params.accountId, + defaultAccountId: resolveDefaultIMessageAccountId(params.cfg), + prompter: params.prompter, + noteTitle: "iMessage allowlist", + noteLines: [ + "Allowlist iMessage DMs by handle or chat target.", + "Examples:", + "- +15555550123", + "- user@example.com", + "- chat_id:123", + "- chat_guid:... or chat_identifier:...", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/imessage", "imessage")}`, + ], + message: "iMessage allowFrom (handle or chat_id)", + placeholder: "+15555550123, user@example.com, chat_id:123", + parseEntries: parseIMessageAllowFromEntries, + getExistingAllowFrom: ({ cfg, accountId }) => + resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? [], + }); +} + +export const imessageSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + imessage: { + ...next.channels?.imessage, + enabled: true, + ...buildIMessageSetupPatch(input), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + imessage: { + ...next.channels?.imessage, + enabled: true, + accounts: { + ...next.channels?.imessage?.accounts, + [accountId]: { + ...next.channels?.imessage?.accounts?.[accountId], + enabled: true, + ...buildIMessageSetupPatch(input), + }, + }, + }, + }, + }; + }, +}; + +export function createIMessageSetupWizardProxy( + loadWizard: () => Promise<{ imessageSetupWizard: ChannelSetupWizard }>, +) { + const imessageDmPolicy: ChannelOnboardingDmPolicy = { + label: "iMessage", + channel, + policyKey: "channels.imessage.dmPolicy", + allowFromKey: "channels.imessage.allowFrom", + getCurrent: (cfg: OpenClawConfig) => cfg.channels?.imessage?.dmPolicy ?? "pairing", + setPolicy: (cfg: OpenClawConfig, policy) => + setChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), + promptAllowFrom: promptIMessageAllowFrom, + }; + + return { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + configuredHint: "imsg found", + unconfiguredHint: "imsg missing", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => + listIMessageAccountIds(cfg).some((accountId) => { + const account = resolveIMessageAccount({ cfg, accountId }); + return Boolean( + account.config.cliPath || + account.config.dbPath || + account.config.allowFrom || + account.config.service || + account.config.region, + ); + }), + resolveStatusLines: async (params) => + (await loadWizard()).imessageSetupWizard.status.resolveStatusLines?.(params) ?? [], + resolveSelectionHint: async (params) => + await (await loadWizard()).imessageSetupWizard.status.resolveSelectionHint?.(params), + resolveQuickstartScore: async (params) => + await (await loadWizard()).imessageSetupWizard.status.resolveQuickstartScore?.(params), + }, + credentials: [], + textInputs: [ + { + inputKey: "cliPath", + message: "imsg CLI path", + initialValue: ({ cfg, accountId }) => + resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", + currentValue: ({ cfg, accountId }) => + resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", + shouldPrompt: async (params) => { + const input = (await loadWizard()).imessageSetupWizard.textInputs?.find( + (entry) => entry.inputKey === "cliPath", + ); + return (await input?.shouldPrompt?.(params)) ?? false; + }, + confirmCurrentValue: false, + applyCurrentValue: true, + helpTitle: "iMessage", + helpLines: ["imsg CLI path required to enable iMessage."], + }, + ], + completionNote: { + title: "iMessage next steps", + lines: [ + "This is still a work in progress.", + "Ensure OpenClaw has Full Disk Access to Messages DB.", + "Grant Automation permission for Messages when prompted.", + "List chats with: imsg chats --limit 20", + `Docs: ${formatDocsLink("/imessage", "imessage")}`, + ], + }, + dmPolicy: imessageDmPolicy, + disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false), + } satisfies ChannelSetupWizard; +} diff --git a/extensions/imessage/src/setup-surface.ts b/extensions/imessage/src/setup-surface.ts index 69382ff4014..90fcf648e60 100644 --- a/extensions/imessage/src/setup-surface.ts +++ b/extensions/imessage/src/setup-surface.ts @@ -5,15 +5,10 @@ import { setChannelDmPolicyWithAllowFrom, setOnboardingChannelEnabled, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import { detectBinary } from "../../../src/commands/onboard-helpers.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { @@ -21,53 +16,10 @@ import { resolveDefaultIMessageAccountId, resolveIMessageAccount, } from "./accounts.js"; -import { normalizeIMessageHandle } from "./targets.js"; +import { imessageSetupAdapter, parseIMessageAllowFromEntries } from "./setup-core.js"; const channel = "imessage" as const; -export function parseIMessageAllowFromEntries(raw: string): { entries: string[]; error?: string } { - return parseOnboardingEntriesAllowingWildcard(raw, (entry) => { - const lower = entry.toLowerCase(); - if (lower.startsWith("chat_id:")) { - const id = entry.slice("chat_id:".length).trim(); - if (!/^\d+$/.test(id)) { - return { error: `Invalid chat_id: ${entry}` }; - } - return { value: entry }; - } - if (lower.startsWith("chat_guid:")) { - if (!entry.slice("chat_guid:".length).trim()) { - return { error: "Invalid chat_guid entry" }; - } - return { value: entry }; - } - if (lower.startsWith("chat_identifier:")) { - if (!entry.slice("chat_identifier:".length).trim()) { - return { error: "Invalid chat_identifier entry" }; - } - return { value: entry }; - } - if (!normalizeIMessageHandle(entry)) { - return { error: `Invalid handle: ${entry}` }; - } - return { value: entry }; - }); -} - -function buildIMessageSetupPatch(input: { - cliPath?: string; - dbPath?: string; - service?: "imessage" | "sms" | "auto"; - region?: string; -}) { - return { - ...(input.cliPath ? { cliPath: input.cliPath } : {}), - ...(input.dbPath ? { dbPath: input.dbPath } : {}), - ...(input.service ? { service: input.service } : {}), - ...(input.region ? { region: input.region } : {}), - }; -} - async function promptIMessageAllowFrom(params: { cfg: OpenClawConfig; prompter: WizardPrompter; @@ -113,63 +65,6 @@ const imessageDmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptIMessageAllowFrom, }; -export const imessageSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - imessage: { - ...next.channels?.imessage, - enabled: true, - ...buildIMessageSetupPatch(input), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - imessage: { - ...next.channels?.imessage, - enabled: true, - accounts: { - ...next.channels?.imessage?.accounts, - [accountId]: { - ...next.channels?.imessage?.accounts?.[accountId], - enabled: true, - ...buildIMessageSetupPatch(input), - }, - }, - }, - }, - }; - }, -}; - export const imessageSetupWizard: ChannelSetupWizard = { channel, status: { @@ -236,3 +131,5 @@ export const imessageSetupWizard: ChannelSetupWizard = { dmPolicy: imessageDmPolicy, disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), }; + +export { imessageSetupAdapter, parseIMessageAllowFromEntries }; diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index 8c8727ef5d9..1d767798873 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -24,10 +24,8 @@ export { resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { - imessageSetupAdapter, - imessageSetupWizard, -} from "../../extensions/imessage/src/setup-surface.js"; +export { imessageSetupWizard } from "../../extensions/imessage/src/setup-surface.js"; +export { imessageSetupAdapter } from "../../extensions/imessage/src/setup-core.js"; export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 04d03c56f8e..2880a60ee58 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -706,10 +706,8 @@ export { resolveIMessageAccount, type ResolvedIMessageAccount, } from "../../extensions/imessage/src/accounts.js"; -export { - imessageSetupAdapter, - imessageSetupWizard, -} from "../../extensions/imessage/src/setup-surface.js"; +export { imessageSetupWizard } from "../../extensions/imessage/src/setup-surface.js"; +export { imessageSetupAdapter } from "../../extensions/imessage/src/setup-core.js"; export { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, From 7d2ddf70c15bb2837bc78d89ec1be4a4cf038812 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 18:59:58 -0700 Subject: [PATCH 106/943] Nextcloud Talk: split setup adapter helpers --- extensions/nextcloud-talk/src/channel.ts | 3 +- extensions/nextcloud-talk/src/setup-core.ts | 235 ++++++++++++++++++ .../nextcloud-talk/src/setup-surface.ts | 148 +---------- 3 files changed, 247 insertions(+), 139 deletions(-) create mode 100644 extensions/nextcloud-talk/src/setup-core.ts diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index b6a2c2ad5ca..77ca7ed36f9 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -33,7 +33,8 @@ import { import { resolveNextcloudTalkGroupToolPolicy } from "./policy.js"; import { getNextcloudTalkRuntime } from "./runtime.js"; import { sendMessageNextcloudTalk } from "./send.js"; -import { nextcloudTalkSetupAdapter, nextcloudTalkSetupWizard } from "./setup-surface.js"; +import { nextcloudTalkSetupAdapter } from "./setup-core.js"; +import { nextcloudTalkSetupWizard } from "./setup-surface.js"; import type { CoreConfig } from "./types.js"; const meta = { diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts new file mode 100644 index 00000000000..9deafc5f71a --- /dev/null +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -0,0 +1,235 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + mergeAllowFromEntries, + resolveOnboardingAccountId, + setOnboardingChannelEnabled, + setTopLevelChannelDmPolicyWithAllowFrom, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + patchScopedAccountConfig, +} from "../../../src/channels/plugins/setup-helpers.js"; +import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { + listNextcloudTalkAccountIds, + resolveDefaultNextcloudTalkAccountId, + resolveNextcloudTalkAccount, +} from "./accounts.js"; +import type { CoreConfig, DmPolicy } from "./types.js"; + +const channel = "nextcloud-talk" as const; + +type NextcloudSetupInput = ChannelSetupInput & { + baseUrl?: string; + secret?: string; + secretFile?: string; +}; +type NextcloudTalkSection = NonNullable["nextcloud-talk"]; + +export function normalizeNextcloudTalkBaseUrl(value: string | undefined): string { + return value?.trim().replace(/\/+$/, "") ?? ""; +} + +export function validateNextcloudTalkBaseUrl(value: string): string | undefined { + if (!value) { + return "Required"; + } + if (!value.startsWith("http://") && !value.startsWith("https://")) { + return "URL must start with http:// or https://"; + } + return undefined; +} + +function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { + return setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy, + }) as CoreConfig; +} + +export function setNextcloudTalkAccountConfig( + cfg: CoreConfig, + accountId: string, + updates: Record, +): CoreConfig { + return patchScopedAccountConfig({ + cfg, + channelKey: channel, + accountId, + patch: updates, + }) as CoreConfig; +} + +export function clearNextcloudTalkAccountFields( + cfg: CoreConfig, + accountId: string, + fields: string[], +): CoreConfig { + const section = cfg.channels?.["nextcloud-talk"]; + if (!section) { + return cfg; + } + + if (accountId === DEFAULT_ACCOUNT_ID) { + const nextSection = { ...section } as Record; + for (const field of fields) { + delete nextSection[field]; + } + return { + ...cfg, + channels: { + ...(cfg.channels ?? {}), + "nextcloud-talk": nextSection as NextcloudTalkSection, + }, + } as CoreConfig; + } + + const currentAccount = section.accounts?.[accountId]; + if (!currentAccount) { + return cfg; + } + + const nextAccount = { ...currentAccount } as Record; + for (const field of fields) { + delete nextAccount[field]; + } + return { + ...cfg, + channels: { + ...(cfg.channels ?? {}), + "nextcloud-talk": { + ...section, + accounts: { + ...section.accounts, + [accountId]: nextAccount as NonNullable[string], + }, + }, + }, + } as CoreConfig; +} + +async function promptNextcloudTalkAllowFrom(params: { + cfg: CoreConfig; + prompter: WizardPrompter; + accountId: string; +}): Promise { + const resolved = resolveNextcloudTalkAccount({ cfg: params.cfg, accountId: params.accountId }); + const existingAllowFrom = resolved.config.allowFrom ?? []; + await params.prompter.note( + [ + "1) Check the Nextcloud admin panel for user IDs", + "2) Or look at the webhook payload logs when someone messages", + "3) User IDs are typically lowercase usernames in Nextcloud", + `Docs: ${formatDocsLink("/channels/nextcloud-talk", "nextcloud-talk")}`, + ].join("\n"), + "Nextcloud Talk user id", + ); + + let resolvedIds: string[] = []; + while (resolvedIds.length === 0) { + const entry = await params.prompter.text({ + message: "Nextcloud Talk allowFrom (user id)", + placeholder: "username", + initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + resolvedIds = String(entry) + .split(/[\n,;]+/g) + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); + if (resolvedIds.length === 0) { + await params.prompter.note("Please enter at least one valid user ID.", "Nextcloud Talk"); + } + } + + return setNextcloudTalkAccountConfig(params.cfg, params.accountId, { + dmPolicy: "allowlist", + allowFrom: mergeAllowFromEntries( + existingAllowFrom.map((value) => String(value).trim().toLowerCase()), + resolvedIds, + ), + }); +} + +async function promptNextcloudTalkAllowFromForAccount(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultNextcloudTalkAccountId(params.cfg as CoreConfig), + }); + return await promptNextcloudTalkAllowFrom({ + cfg: params.cfg as CoreConfig, + prompter: params.prompter, + accountId, + }); +} + +const nextcloudTalkDmPolicy: ChannelOnboardingDmPolicy = { + label: "Nextcloud Talk", + channel, + policyKey: "channels.nextcloud-talk.dmPolicy", + allowFromKey: "channels.nextcloud-talk.allowFrom", + getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy), + promptAllowFrom: promptNextcloudTalkAllowFromForAccount, +}; + +export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + const setupInput = input as NextcloudSetupInput; + if (setupInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account."; + } + if (!setupInput.useEnv && !setupInput.secret && !setupInput.secretFile) { + return "Nextcloud Talk requires bot secret or --secret-file (or --use-env)."; + } + if (!setupInput.baseUrl) { + return "Nextcloud Talk requires --base-url."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const setupInput = input as NextcloudSetupInput; + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: setupInput.name, + }); + const next = setupInput.useEnv + ? clearNextcloudTalkAccountFields(namedConfig as CoreConfig, accountId, [ + "botSecret", + "botSecretFile", + ]) + : namedConfig; + const patch = { + baseUrl: normalizeNextcloudTalkBaseUrl(setupInput.baseUrl), + ...(setupInput.useEnv + ? {} + : setupInput.secretFile + ? { botSecretFile: setupInput.secretFile } + : setupInput.secret + ? { botSecret: setupInput.secret } + : {}), + }; + return setNextcloudTalkAccountConfig(next as CoreConfig, accountId, patch); + }, +}; diff --git a/extensions/nextcloud-talk/src/setup-surface.ts b/extensions/nextcloud-talk/src/setup-surface.ts index 758ae4d3214..4fcb874b5d3 100644 --- a/extensions/nextcloud-talk/src/setup-surface.ts +++ b/extensions/nextcloud-talk/src/setup-surface.ts @@ -5,16 +5,11 @@ import { setOnboardingChannelEnabled, setTopLevelChannelDmPolicyWithAllowFrom, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - patchScopedAccountConfig, -} from "../../../src/channels/plugins/setup-helpers.js"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { @@ -22,32 +17,18 @@ import { resolveDefaultNextcloudTalkAccountId, resolveNextcloudTalkAccount, } from "./accounts.js"; +import { + clearNextcloudTalkAccountFields, + nextcloudTalkSetupAdapter, + normalizeNextcloudTalkBaseUrl, + setNextcloudTalkAccountConfig, + validateNextcloudTalkBaseUrl, +} from "./setup-core.js"; import type { CoreConfig, DmPolicy } from "./types.js"; const channel = "nextcloud-talk" as const; const CONFIGURE_API_FLAG = "__nextcloudTalkConfigureApiCredentials"; -type NextcloudSetupInput = ChannelSetupInput & { - baseUrl?: string; - secret?: string; - secretFile?: string; -}; -type NextcloudTalkSection = NonNullable["nextcloud-talk"]; - -function normalizeNextcloudTalkBaseUrl(value: string | undefined): string { - return value?.trim().replace(/\/+$/, "") ?? ""; -} - -function validateNextcloudTalkBaseUrl(value: string): string | undefined { - if (!value) { - return "Required"; - } - if (!value.startsWith("http://") && !value.startsWith("https://")) { - return "URL must start with http:// or https://"; - } - return undefined; -} - function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { return setTopLevelChannelDmPolicyWithAllowFrom({ cfg, @@ -56,67 +37,6 @@ function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConf }) as CoreConfig; } -function setNextcloudTalkAccountConfig( - cfg: CoreConfig, - accountId: string, - updates: Record, -): CoreConfig { - return patchScopedAccountConfig({ - cfg, - channelKey: channel, - accountId, - patch: updates, - }) as CoreConfig; -} - -function clearNextcloudTalkAccountFields( - cfg: CoreConfig, - accountId: string, - fields: string[], -): CoreConfig { - const section = cfg.channels?.["nextcloud-talk"]; - if (!section) { - return cfg; - } - - if (accountId === DEFAULT_ACCOUNT_ID) { - const nextSection = { ...section } as Record; - for (const field of fields) { - delete nextSection[field]; - } - return { - ...cfg, - channels: { - ...(cfg.channels ?? {}), - "nextcloud-talk": nextSection as NextcloudTalkSection, - }, - } as CoreConfig; - } - - const currentAccount = section.accounts?.[accountId]; - if (!currentAccount) { - return cfg; - } - - const nextAccount = { ...currentAccount } as Record; - for (const field of fields) { - delete nextAccount[field]; - } - return { - ...cfg, - channels: { - ...(cfg.channels ?? {}), - "nextcloud-talk": { - ...section, - accounts: { - ...section.accounts, - [accountId]: nextAccount as NonNullable[string], - }, - }, - }, - } as CoreConfig; -} - async function promptNextcloudTalkAllowFrom(params: { cfg: CoreConfig; prompter: WizardPrompter; @@ -186,56 +106,6 @@ const nextcloudTalkDmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptNextcloudTalkAllowFromForAccount, }; -export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - const setupInput = input as NextcloudSetupInput; - if (setupInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account."; - } - if (!setupInput.useEnv && !setupInput.secret && !setupInput.secretFile) { - return "Nextcloud Talk requires bot secret or --secret-file (or --use-env)."; - } - if (!setupInput.baseUrl) { - return "Nextcloud Talk requires --base-url."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const setupInput = input as NextcloudSetupInput; - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: setupInput.name, - }); - const next = setupInput.useEnv - ? clearNextcloudTalkAccountFields(namedConfig as CoreConfig, accountId, [ - "botSecret", - "botSecretFile", - ]) - : namedConfig; - const patch = { - baseUrl: normalizeNextcloudTalkBaseUrl(setupInput.baseUrl), - ...(setupInput.useEnv - ? {} - : setupInput.secretFile - ? { botSecretFile: setupInput.secretFile } - : setupInput.secret - ? { botSecret: setupInput.secret } - : {}), - }; - return setNextcloudTalkAccountConfig(next as CoreConfig, accountId, patch); - }, -}; - export const nextcloudTalkSetupWizard: ChannelSetupWizard = { channel, stepOrder: "text-first", @@ -404,3 +274,5 @@ export const nextcloudTalkSetupWizard: ChannelSetupWizard = { dmPolicy: nextcloudTalkDmPolicy, disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), }; + +export { nextcloudTalkSetupAdapter }; From 70a6d40d37efb01debf7ddac8dd3debc9cf89651 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 02:10:28 +0000 Subject: [PATCH 107/943] fix: remove stale dist plugin dirs --- scripts/copy-bundled-plugin-metadata.mjs | 11 +----- .../copy-bundled-plugin-metadata.test.ts | 38 +++++++++++++------ 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs index f5eac7ba513..b4be20dfae4 100644 --- a/scripts/copy-bundled-plugin-metadata.mjs +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -135,13 +135,6 @@ export function copyBundledPluginMetadata(params = {}) { } const sourcePluginDirs = new Set(); - const removeGeneratedPluginArtifacts = (distPluginDir) => { - removeFileIfExists(path.join(distPluginDir, "openclaw.plugin.json")); - removeFileIfExists(path.join(distPluginDir, "package.json")); - removePathIfExists(path.join(distPluginDir, GENERATED_BUNDLED_SKILLS_DIR)); - removePathIfExists(path.join(distPluginDir, "node_modules")); - }; - for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) { if (!dirent.isDirectory()) { continue; @@ -154,7 +147,7 @@ export function copyBundledPluginMetadata(params = {}) { const distManifestPath = path.join(distPluginDir, "openclaw.plugin.json"); const distPackageJsonPath = path.join(distPluginDir, "package.json"); if (!fs.existsSync(manifestPath)) { - removeGeneratedPluginArtifacts(distPluginDir); + removePathIfExists(distPluginDir); continue; } @@ -203,7 +196,7 @@ export function copyBundledPluginMetadata(params = {}) { continue; } const distPluginDir = path.join(distExtensionsRoot, dirent.name); - removeGeneratedPluginArtifacts(distPluginDir); + removePathIfExists(distPluginDir); } } diff --git a/src/plugins/copy-bundled-plugin-metadata.test.ts b/src/plugins/copy-bundled-plugin-metadata.test.ts index 88da85b0dda..8f4187a8937 100644 --- a/src/plugins/copy-bundled-plugin-metadata.test.ts +++ b/src/plugins/copy-bundled-plugin-metadata.test.ts @@ -258,6 +258,11 @@ describe("copyBundledPluginMetadata", () => { "node_modules", ); fs.mkdirSync(staleNodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(repoRoot, "dist", "extensions", "removed-plugin", "index.js"), + "export default {}\n", + "utf8", + ); writeJson(path.join(repoRoot, "dist", "extensions", "removed-plugin", "openclaw.plugin.json"), { id: "removed-plugin", configSchema: { type: "object" }, @@ -270,17 +275,26 @@ describe("copyBundledPluginMetadata", () => { copyBundledPluginMetadata({ repoRoot }); - expect( - fs.existsSync( - path.join(repoRoot, "dist", "extensions", "removed-plugin", "openclaw.plugin.json"), - ), - ).toBe(false); - expect( - fs.existsSync(path.join(repoRoot, "dist", "extensions", "removed-plugin", "package.json")), - ).toBe(false); - expect( - fs.existsSync(path.join(repoRoot, "dist", "extensions", "removed-plugin", "bundled-skills")), - ).toBe(false); - expect(fs.existsSync(staleNodeModulesDir)).toBe(false); + expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "removed-plugin"))).toBe(false); + }); + + it("removes stale dist outputs when a source extension directory no longer has a manifest", () => { + const repoRoot = makeRepoRoot("openclaw-bundled-plugin-manifestless-source-"); + const sourcePluginDir = path.join(repoRoot, "extensions", "google-gemini-cli-auth"); + fs.mkdirSync(path.join(sourcePluginDir, "node_modules"), { recursive: true }); + const staleDistDir = path.join(repoRoot, "dist", "extensions", "google-gemini-cli-auth"); + fs.mkdirSync(staleDistDir, { recursive: true }); + fs.writeFileSync(path.join(staleDistDir, "index.js"), "export default {}\n", "utf8"); + writeJson(path.join(staleDistDir, "openclaw.plugin.json"), { + id: "google-gemini-cli-auth", + configSchema: { type: "object" }, + }); + writeJson(path.join(staleDistDir, "package.json"), { + name: "@openclaw/google-gemini-cli-auth", + }); + + copyBundledPluginMetadata({ repoRoot }); + + expect(fs.existsSync(staleDistDir)).toBe(false); }); }); From 4ed30abc7ac2620ce7c5292fbede3b11b80da7e6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:10:22 -0700 Subject: [PATCH 108/943] BlueBubbles: split setup adapter helpers --- extensions/bluebubbles/src/channel.ts | 3 +- extensions/bluebubbles/src/setup-core.ts | 84 ++++++++++++++++++++ extensions/bluebubbles/src/setup-surface.ts | 87 ++------------------- 3 files changed, 94 insertions(+), 80 deletions(-) create mode 100644 extensions/bluebubbles/src/setup-core.ts diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index a482632ebea..d6d1a3130fb 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -31,7 +31,8 @@ import { resolveBlueBubblesMessageId } from "./monitor.js"; import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js"; import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js"; import { sendMessageBlueBubbles } from "./send.js"; -import { blueBubblesSetupAdapter, blueBubblesSetupWizard } from "./setup-surface.js"; +import { blueBubblesSetupAdapter } from "./setup-core.js"; +import { blueBubblesSetupWizard } from "./setup-surface.js"; import { extractHandleFromChatGuid, looksLikeBlueBubblesTargetId, diff --git a/extensions/bluebubbles/src/setup-core.ts b/extensions/bluebubbles/src/setup-core.ts new file mode 100644 index 00000000000..930fa29a64e --- /dev/null +++ b/extensions/bluebubbles/src/setup-core.ts @@ -0,0 +1,84 @@ +import { setTopLevelChannelDmPolicyWithAllowFrom } from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, + patchScopedAccountConfig, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DmPolicy } from "../../../src/config/types.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { applyBlueBubblesConnectionConfig } from "./config-apply.js"; + +const channel = "bluebubbles" as const; + +export function setBlueBubblesDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { + return setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy, + }); +} + +export function setBlueBubblesAllowFrom( + cfg: OpenClawConfig, + accountId: string, + allowFrom: string[], +): OpenClawConfig { + return patchScopedAccountConfig({ + cfg, + channelKey: channel, + accountId, + patch: { allowFrom }, + ensureChannelEnabled: false, + ensureAccountEnabled: false, + }); +} + +export const blueBubblesSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ input }) => { + if (!input.httpUrl && !input.password) { + return "BlueBubbles requires --http-url and --password."; + } + if (!input.httpUrl) { + return "BlueBubbles requires --http-url."; + } + if (!input.password) { + return "BlueBubbles requires --password."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + return applyBlueBubblesConnectionConfig({ + cfg: next, + accountId, + patch: { + serverUrl: input.httpUrl, + password: input.password, + webhookPath: input.webhookPath, + }, + onlyDefinedFields: true, + }); + }, +}; diff --git a/extensions/bluebubbles/src/setup-surface.ts b/extensions/bluebubbles/src/setup-surface.ts index 0cb23998663..f4ee2d98db4 100644 --- a/extensions/bluebubbles/src/setup-surface.ts +++ b/extensions/bluebubbles/src/setup-surface.ts @@ -2,18 +2,11 @@ import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/on import { mergeAllowFromEntries, resolveOnboardingAccountId, - setTopLevelChannelDmPolicyWithAllowFrom, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, - patchScopedAccountConfig, -} from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DmPolicy } from "../../../src/config/types.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { @@ -24,35 +17,17 @@ import { import { applyBlueBubblesConnectionConfig } from "./config-apply.js"; import { DEFAULT_WEBHOOK_PATH } from "./monitor-shared.js"; import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js"; +import { + blueBubblesSetupAdapter, + setBlueBubblesAllowFrom, + setBlueBubblesDmPolicy, +} from "./setup-core.js"; import { parseBlueBubblesAllowTarget } from "./targets.js"; import { normalizeBlueBubblesServerUrl } from "./types.js"; const channel = "bluebubbles" as const; const CONFIGURE_CUSTOM_WEBHOOK_FLAG = "__bluebubblesConfigureCustomWebhookPath"; -function setBlueBubblesDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }); -} - -function setBlueBubblesAllowFrom( - cfg: OpenClawConfig, - accountId: string, - allowFrom: string[], -): OpenClawConfig { - return patchScopedAccountConfig({ - cfg, - channelKey: channel, - accountId, - patch: { allowFrom }, - ensureChannelEnabled: false, - ensureAccountEnabled: false, - }); -} - function parseBlueBubblesAllowFromInput(raw: string): string[] { return raw .split(/[\n,]+/g) @@ -183,54 +158,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptBlueBubblesAllowFrom, }; -export const blueBubblesSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ input }) => { - if (!input.httpUrl && !input.password) { - return "BlueBubbles requires --http-url and --password."; - } - if (!input.httpUrl) { - return "BlueBubbles requires --http-url."; - } - if (!input.password) { - return "BlueBubbles requires --password."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - return applyBlueBubblesConnectionConfig({ - cfg: next, - accountId, - patch: { - serverUrl: input.httpUrl, - password: input.password, - webhookPath: input.webhookPath, - }, - onlyDefinedFields: true, - }); - }, -}; - export const blueBubblesSetupWizard: ChannelSetupWizard = { channel, stepOrder: "text-first", @@ -383,3 +310,5 @@ export const blueBubblesSetupWizard: ChannelSetupWizard = { }, }), }; + +export { blueBubblesSetupAdapter }; From 6b28668104bb67fc7c689763d89e676c42b51235 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:21:49 +0000 Subject: [PATCH 109/943] test(plugins): cover retired google auth compatibility --- extensions/google/gemini-cli-provider.test.ts | 20 ++++++++- src/config/config.plugin-validation.test.ts | 41 +++++++++++++++++++ src/config/validation.ts | 2 +- src/plugins/providers.test.ts | 24 +++++++++-- 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/extensions/google/gemini-cli-provider.test.ts b/extensions/google/gemini-cli-provider.test.ts index ad5969c7c4d..dd991e2b32d 100644 --- a/extensions/google/gemini-cli-provider.test.ts +++ b/extensions/google/gemini-cli-provider.test.ts @@ -8,22 +8,33 @@ import googlePlugin from "./index.js"; function registerGooglePlugin(): { provider: ProviderPlugin; + webSearchProvider: { + id: string; + envVars: string[]; + label: string; + } | null; webSearchProviderRegistered: boolean; } { let provider: ProviderPlugin | undefined; let webSearchProviderRegistered = false; + let webSearchProvider: { + id: string; + envVars: string[]; + label: string; + } | null = null; googlePlugin.register({ registerProvider(nextProvider: ProviderPlugin) { provider = nextProvider; }, - registerWebSearchProvider() { + registerWebSearchProvider(nextProvider: { id: string; envVars: string[]; label: string }) { webSearchProviderRegistered = true; + webSearchProvider = nextProvider; }, } as never); if (!provider) { throw new Error("provider registration missing"); } - return { provider, webSearchProviderRegistered }; + return { provider, webSearchProviderRegistered, webSearchProvider }; } describe("google plugin", () => { @@ -32,6 +43,11 @@ describe("google plugin", () => { expect(result.provider.id).toBe("google-gemini-cli"); expect(result.webSearchProviderRegistered).toBe(true); + expect(result.webSearchProvider).toMatchObject({ + id: "gemini", + label: "Gemini (Google Search)", + envVars: ["GEMINI_API_KEY"], + }); }); it("owns gemini 3.1 forward-compat resolution", () => { diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index efb84acdacf..42d473aed4e 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -281,6 +281,47 @@ describe("config plugin validation", () => { } }); + it("warns for removed google gemini auth plugin ids instead of failing validation", async () => { + const removedId = "google-gemini-cli-auth"; + const res = validateInSuite({ + agents: { list: [{ id: "pi" }] }, + plugins: { + enabled: false, + entries: { [removedId]: { enabled: true } }, + allow: [removedId], + deny: [removedId], + slots: { memory: removedId }, + }, + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.warnings).toEqual( + expect.arrayContaining([ + { + path: `plugins.entries.${removedId}`, + message: + "plugin removed: google-gemini-cli-auth (stale config entry ignored; remove it from plugins config)", + }, + { + path: "plugins.allow", + message: + "plugin removed: google-gemini-cli-auth (stale config entry ignored; remove it from plugins config)", + }, + { + path: "plugins.deny", + message: + "plugin removed: google-gemini-cli-auth (stale config entry ignored; remove it from plugins config)", + }, + { + path: "plugins.slots.memory", + message: + "plugin removed: google-gemini-cli-auth (stale config entry ignored; remove it from plugins config)", + }, + ]), + ); + } + }); + it("surfaces plugin config diagnostics", async () => { const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, diff --git a/src/config/validation.ts b/src/config/validation.ts index e97bd8cbedf..2a2c08b96ee 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -24,7 +24,7 @@ import { findLegacyConfigIssues } from "./legacy.js"; import type { OpenClawConfig, ConfigValidationIssue } from "./types.js"; import { OpenClawSchema } from "./zod-schema.js"; -const LEGACY_REMOVED_PLUGIN_IDS = new Set(["google-antigravity-auth"]); +const LEGACY_REMOVED_PLUGIN_IDS = new Set(["google-antigravity-auth", "google-gemini-cli-auth"]); type UnknownIssueRecord = Record; type AllowedValuesCollection = { diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index 4e238c2193d..86ffb8e5ffc 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -11,7 +11,7 @@ describe("resolvePluginProviders", () => { beforeEach(() => { loadOpenClawPluginsMock.mockReset(); loadOpenClawPluginsMock.mockReturnValue({ - providers: [{ provider: { id: "demo-provider" } }], + providers: [{ pluginId: "google", provider: { id: "demo-provider" } }], }); }); @@ -23,7 +23,7 @@ describe("resolvePluginProviders", () => { env, }); - expect(providers).toEqual([{ id: "demo-provider" }]); + expect(providers).toEqual([{ id: "demo-provider", pluginId: "google" }]); expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( expect.objectContaining({ workspaceDir: "/workspace/explicit", @@ -46,13 +46,12 @@ describe("resolvePluginProviders", () => { expect.objectContaining({ config: expect.objectContaining({ plugins: expect.objectContaining({ - allow: expect.arrayContaining(["openrouter", "kilocode", "moonshot"]), + allow: expect.arrayContaining(["openrouter", "google", "kilocode", "moonshot"]), }), }), }), ); }); - it("can enable bundled provider plugins under Vitest when no explicit plugin config exists", () => { resolvePluginProviders({ env: { VITEST: "1" } as NodeJS.ProcessEnv, @@ -70,4 +69,21 @@ describe("resolvePluginProviders", () => { }), ); }); + + it("does not reintroduce the retired google auth plugin id into compat allowlists", () => { + resolvePluginProviders({ + config: { + plugins: { + allow: ["openrouter"], + }, + }, + bundledProviderAllowlistCompat: true, + }); + + const call = loadOpenClawPluginsMock.mock.calls.at(-1)?.[0]; + const allow = call?.config?.plugins?.allow; + + expect(allow).toContain("google"); + expect(allow).not.toContain("google-gemini-cli-auth"); + }); }); From bb76a90dd1843af801798d64f9c35c31d6118e15 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:23:45 +0000 Subject: [PATCH 110/943] refactor(tests): share plugin registration helpers --- extensions/anthropic/index.test.ts | 15 +------ extensions/github-copilot/index.test.ts | 15 +------ extensions/google/gemini-cli-provider.test.ts | 34 +++++++-------- extensions/zai/index.test.ts | 15 +------ src/test-utils/plugin-registration.ts | 41 +++++++++++++++++++ 5 files changed, 64 insertions(+), 56 deletions(-) create mode 100644 src/test-utils/plugin-registration.ts diff --git a/extensions/anthropic/index.test.ts b/extensions/anthropic/index.test.ts index 00fe6ba74ee..172a7099e4d 100644 --- a/extensions/anthropic/index.test.ts +++ b/extensions/anthropic/index.test.ts @@ -1,23 +1,12 @@ import { describe, expect, it } from "vitest"; -import type { ProviderPlugin } from "../../src/plugins/types.js"; +import { registerSingleProviderPlugin } from "../../src/test-utils/plugin-registration.js"; import { createProviderUsageFetch, makeResponse, } from "../../src/test-utils/provider-usage-fetch.js"; import anthropicPlugin from "./index.js"; -function registerProvider(): ProviderPlugin { - let provider: ProviderPlugin | undefined; - anthropicPlugin.register({ - registerProvider(nextProvider: ProviderPlugin) { - provider = nextProvider; - }, - } as never); - if (!provider) { - throw new Error("provider registration missing"); - } - return provider; -} +const registerProvider = () => registerSingleProviderPlugin(anthropicPlugin); describe("anthropic plugin", () => { it("owns anthropic 4.6 forward-compat resolution", () => { diff --git a/extensions/github-copilot/index.test.ts b/extensions/github-copilot/index.test.ts index e69fee13b88..633d1f1ad75 100644 --- a/extensions/github-copilot/index.test.ts +++ b/extensions/github-copilot/index.test.ts @@ -1,19 +1,8 @@ import { describe, expect, it } from "vitest"; -import type { ProviderPlugin } from "../../src/plugins/types.js"; +import { registerSingleProviderPlugin } from "../../src/test-utils/plugin-registration.js"; import githubCopilotPlugin from "./index.js"; -function registerProvider(): ProviderPlugin { - let provider: ProviderPlugin | undefined; - githubCopilotPlugin.register({ - registerProvider(nextProvider: ProviderPlugin) { - provider = nextProvider; - }, - } as never); - if (!provider) { - throw new Error("provider registration missing"); - } - return provider; -} +const registerProvider = () => registerSingleProviderPlugin(githubCopilotPlugin); describe("github-copilot plugin", () => { it("owns Copilot-specific forward-compat fallbacks", () => { diff --git a/extensions/google/gemini-cli-provider.test.ts b/extensions/google/gemini-cli-provider.test.ts index dd991e2b32d..341ecd9e0b9 100644 --- a/extensions/google/gemini-cli-provider.test.ts +++ b/extensions/google/gemini-cli-provider.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ProviderPlugin } from "../../src/plugins/types.js"; +import { createCapturedPluginRegistration } from "../../src/test-utils/plugin-registration.js"; import { createProviderUsageFetch, makeResponse, @@ -15,26 +16,25 @@ function registerGooglePlugin(): { } | null; webSearchProviderRegistered: boolean; } { - let provider: ProviderPlugin | undefined; - let webSearchProviderRegistered = false; - let webSearchProvider: { - id: string; - envVars: string[]; - label: string; - } | null = null; - googlePlugin.register({ - registerProvider(nextProvider: ProviderPlugin) { - provider = nextProvider; - }, - registerWebSearchProvider(nextProvider: { id: string; envVars: string[]; label: string }) { - webSearchProviderRegistered = true; - webSearchProvider = nextProvider; - }, - } as never); + const captured = createCapturedPluginRegistration(); + googlePlugin.register(captured.api); + const provider = captured.providers[0]; if (!provider) { throw new Error("provider registration missing"); } - return { provider, webSearchProviderRegistered, webSearchProvider }; + const webSearchProvider = captured.webSearchProviders[0] ?? null; + return { + provider, + webSearchProviderRegistered: webSearchProvider !== null, + webSearchProvider: + webSearchProvider === null + ? null + : { + id: webSearchProvider.id, + envVars: webSearchProvider.envVars, + label: webSearchProvider.label, + }, + }; } describe("google plugin", () => { diff --git a/extensions/zai/index.test.ts b/extensions/zai/index.test.ts index 119309d31a3..f79f53670b7 100644 --- a/extensions/zai/index.test.ts +++ b/extensions/zai/index.test.ts @@ -1,23 +1,12 @@ import { describe, expect, it } from "vitest"; -import type { ProviderPlugin } from "../../src/plugins/types.js"; +import { registerSingleProviderPlugin } from "../../src/test-utils/plugin-registration.js"; import { createProviderUsageFetch, makeResponse, } from "../../src/test-utils/provider-usage-fetch.js"; import zaiPlugin from "./index.js"; -function registerProvider(): ProviderPlugin { - let provider: ProviderPlugin | undefined; - zaiPlugin.register({ - registerProvider(nextProvider: ProviderPlugin) { - provider = nextProvider; - }, - } as never); - if (!provider) { - throw new Error("provider registration missing"); - } - return provider; -} +const registerProvider = () => registerSingleProviderPlugin(zaiPlugin); describe("zai plugin", () => { it("owns glm-5 forward-compat resolution", () => { diff --git a/src/test-utils/plugin-registration.ts b/src/test-utils/plugin-registration.ts new file mode 100644 index 00000000000..fe89acc5d92 --- /dev/null +++ b/src/test-utils/plugin-registration.ts @@ -0,0 +1,41 @@ +import type { + OpenClawPluginApi, + ProviderPlugin, + WebSearchProviderPlugin, +} from "../plugins/types.js"; + +export type CapturedPluginRegistration = { + api: OpenClawPluginApi; + providers: ProviderPlugin[]; + webSearchProviders: WebSearchProviderPlugin[]; +}; + +export function createCapturedPluginRegistration(): CapturedPluginRegistration { + const providers: ProviderPlugin[] = []; + const webSearchProviders: WebSearchProviderPlugin[] = []; + + return { + providers, + webSearchProviders, + api: { + registerProvider(provider: ProviderPlugin) { + providers.push(provider); + }, + registerWebSearchProvider(provider: WebSearchProviderPlugin) { + webSearchProviders.push(provider); + }, + } as OpenClawPluginApi, + }; +} + +export function registerSingleProviderPlugin(params: { + register(api: OpenClawPluginApi): void; +}): ProviderPlugin { + const captured = createCapturedPluginRegistration(); + params.register(captured.api); + const provider = captured.providers[0]; + if (!provider) { + throw new Error("provider registration missing"); + } + return provider; +} From 7c0cac2740c8526598867fa39a3e28a638b8275a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:24:26 +0000 Subject: [PATCH 111/943] refactor(plugins): share bundled compat transforms --- src/plugins/bundled-compat.ts | 65 ++++++++++++++++++++++++ src/plugins/providers.ts | 39 ++------------- src/plugins/web-search-providers.ts | 76 +++++------------------------ 3 files changed, 82 insertions(+), 98 deletions(-) create mode 100644 src/plugins/bundled-compat.ts diff --git a/src/plugins/bundled-compat.ts b/src/plugins/bundled-compat.ts new file mode 100644 index 00000000000..e946be355b5 --- /dev/null +++ b/src/plugins/bundled-compat.ts @@ -0,0 +1,65 @@ +import type { PluginEntryConfig } from "../config/types.plugins.js"; +import type { PluginLoadOptions } from "./loader.js"; + +export function withBundledPluginAllowlistCompat(params: { + config: PluginLoadOptions["config"]; + pluginIds: readonly string[]; +}): PluginLoadOptions["config"] { + const allow = params.config?.plugins?.allow; + if (!Array.isArray(allow) || allow.length === 0) { + return params.config; + } + + const allowSet = new Set(allow.map((entry) => entry.trim()).filter(Boolean)); + let changed = false; + for (const pluginId of params.pluginIds) { + if (!allowSet.has(pluginId)) { + allowSet.add(pluginId); + changed = true; + } + } + + if (!changed) { + return params.config; + } + + return { + ...params.config, + plugins: { + ...params.config?.plugins, + allow: [...allowSet], + }, + }; +} + +export function withBundledPluginEnablementCompat(params: { + config: PluginLoadOptions["config"]; + pluginIds: readonly string[]; +}): PluginLoadOptions["config"] { + const existingEntries = params.config?.plugins?.entries ?? {}; + let changed = false; + const nextEntries: Record = { ...existingEntries }; + + for (const pluginId of params.pluginIds) { + if (existingEntries[pluginId] !== undefined) { + continue; + } + nextEntries[pluginId] = { enabled: true }; + changed = true; + } + + if (!changed) { + return params.config; + } + + return { + ...params.config, + plugins: { + ...params.config?.plugins, + entries: { + ...existingEntries, + ...nextEntries, + }, + }, + }; +} diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 010766e5fa9..4f4216730cf 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -1,4 +1,5 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; +import { withBundledPluginAllowlistCompat } from "./bundled-compat.js"; import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; import type { ProviderPlugin } from "./types.js"; @@ -64,38 +65,6 @@ function hasExplicitPluginConfig(config: PluginLoadOptions["config"]): boolean { return false; } -function withBundledProviderAllowlistCompat( - config: PluginLoadOptions["config"], -): PluginLoadOptions["config"] { - const allow = config?.plugins?.allow; - if (!Array.isArray(allow) || allow.length === 0) { - return config; - } - - const allowSet = new Set(allow.map((entry) => entry.trim()).filter(Boolean)); - let changed = false; - for (const pluginId of BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS) { - if (!allowSet.has(pluginId)) { - allowSet.add(pluginId); - changed = true; - } - } - - if (!changed) { - return config; - } - - return { - ...config, - plugins: { - ...config?.plugins, - // Backward compat: bundled implicit providers historically stayed - // available even when operators kept a restrictive plugin allowlist. - allow: [...allowSet], - }, - }; -} - function withBundledProviderVitestCompat(params: { config: PluginLoadOptions["config"]; env?: PluginLoadOptions["env"]; @@ -118,7 +87,6 @@ function withBundledProviderVitestCompat(params: { }, }; } - export function resolvePluginProviders(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; @@ -129,7 +97,10 @@ export function resolvePluginProviders(params: { onlyPluginIds?: string[]; }): ProviderPlugin[] { const maybeAllowlistCompat = params.bundledProviderAllowlistCompat - ? withBundledProviderAllowlistCompat(params.config) + ? withBundledPluginAllowlistCompat({ + config: params.config, + pluginIds: BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS, + }) : params.config; const config = params.bundledProviderVitestCompat ? withBundledProviderVitestCompat({ diff --git a/src/plugins/web-search-providers.ts b/src/plugins/web-search-providers.ts index 8120be0113c..f59cf95f51a 100644 --- a/src/plugins/web-search-providers.ts +++ b/src/plugins/web-search-providers.ts @@ -1,5 +1,8 @@ -import type { PluginEntryConfig } from "../config/types.plugins.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { + withBundledPluginAllowlistCompat, + withBundledPluginEnablementCompat, +} from "./bundled-compat.js"; import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; import type { WebSearchProviderPlugin } from "./types.js"; @@ -14,67 +17,6 @@ const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "xai", ] as const; -function withBundledWebSearchAllowlistCompat( - config: PluginLoadOptions["config"], -): PluginLoadOptions["config"] { - const allow = config?.plugins?.allow; - if (!Array.isArray(allow) || allow.length === 0) { - return config; - } - - const allowSet = new Set(allow.map((entry) => entry.trim()).filter(Boolean)); - let changed = false; - for (const pluginId of BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS) { - if (!allowSet.has(pluginId)) { - allowSet.add(pluginId); - changed = true; - } - } - - if (!changed) { - return config; - } - - return { - ...config, - plugins: { - ...config?.plugins, - allow: [...allowSet], - }, - }; -} - -function withBundledWebSearchEnablementCompat( - config: PluginLoadOptions["config"], -): PluginLoadOptions["config"] { - const existingEntries = config?.plugins?.entries ?? {}; - let changed = false; - const nextEntries: Record = { ...existingEntries }; - - for (const pluginId of BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS) { - if (existingEntries[pluginId] !== undefined) { - continue; - } - nextEntries[pluginId] = { enabled: true }; - changed = true; - } - - if (!changed) { - return config; - } - - return { - ...config, - plugins: { - ...config?.plugins, - entries: { - ...existingEntries, - ...nextEntries, - }, - }, - }; -} - export function resolvePluginWebSearchProviders(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; @@ -82,9 +24,15 @@ export function resolvePluginWebSearchProviders(params: { bundledAllowlistCompat?: boolean; }): WebSearchProviderPlugin[] { const allowlistCompat = params.bundledAllowlistCompat - ? withBundledWebSearchAllowlistCompat(params.config) + ? withBundledPluginAllowlistCompat({ + config: params.config, + pluginIds: BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS, + }) : params.config; - const config = withBundledWebSearchEnablementCompat(allowlistCompat); + const config = withBundledPluginEnablementCompat({ + config: allowlistCompat, + pluginIds: BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS, + }); const registry = loadOpenClawPlugins({ config, workspaceDir: params.workspaceDir, From 92e765cdee15c445af94e0b2c2ac6d03f907f56f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:27:38 +0000 Subject: [PATCH 112/943] refactor(google): split oauth flow modules --- extensions/google/oauth.credentials.ts | 163 ++++++ extensions/google/oauth.flow.ts | 152 ++++++ extensions/google/oauth.http.ts | 24 + extensions/google/oauth.project.ts | 235 +++++++++ extensions/google/oauth.shared.ts | 44 ++ extensions/google/oauth.token.ts | 57 +++ extensions/google/oauth.ts | 671 +------------------------ 7 files changed, 688 insertions(+), 658 deletions(-) create mode 100644 extensions/google/oauth.credentials.ts create mode 100644 extensions/google/oauth.flow.ts create mode 100644 extensions/google/oauth.http.ts create mode 100644 extensions/google/oauth.project.ts create mode 100644 extensions/google/oauth.shared.ts create mode 100644 extensions/google/oauth.token.ts diff --git a/extensions/google/oauth.credentials.ts b/extensions/google/oauth.credentials.ts new file mode 100644 index 00000000000..1c1e88db042 --- /dev/null +++ b/extensions/google/oauth.credentials.ts @@ -0,0 +1,163 @@ +import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; +import { delimiter, dirname, join } from "node:path"; +import { CLIENT_ID_KEYS, CLIENT_SECRET_KEYS } from "./oauth.shared.js"; + +function resolveEnv(keys: string[]): string | undefined { + for (const key of keys) { + const value = process.env[key]?.trim(); + if (value) { + return value; + } + } + return undefined; +} + +let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null; + +export function clearCredentialsCache(): void { + cachedGeminiCliCredentials = null; +} + +export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null { + if (cachedGeminiCliCredentials) { + return cachedGeminiCliCredentials; + } + + try { + const geminiPath = findInPath("gemini"); + if (!geminiPath) { + return null; + } + + const resolvedPath = realpathSync(geminiPath); + const geminiCliDirs = resolveGeminiCliDirs(geminiPath, resolvedPath); + + let content: string | null = null; + for (const geminiCliDir of geminiCliDirs) { + const searchPaths = [ + join( + geminiCliDir, + "node_modules", + "@google", + "gemini-cli-core", + "dist", + "src", + "code_assist", + "oauth2.js", + ), + join( + geminiCliDir, + "node_modules", + "@google", + "gemini-cli-core", + "dist", + "code_assist", + "oauth2.js", + ), + ]; + + for (const path of searchPaths) { + if (existsSync(path)) { + content = readFileSync(path, "utf8"); + break; + } + } + if (content) { + break; + } + const found = findFile(geminiCliDir, "oauth2.js", 10); + if (found) { + content = readFileSync(found, "utf8"); + break; + } + } + if (!content) { + return null; + } + + const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/); + const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/); + if (idMatch && secretMatch) { + cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] }; + return cachedGeminiCliCredentials; + } + } catch { + // Gemini CLI not installed or extraction failed + } + return null; +} + +function resolveGeminiCliDirs(geminiPath: string, resolvedPath: string): string[] { + const binDir = dirname(geminiPath); + const candidates = [ + dirname(dirname(resolvedPath)), + join(dirname(resolvedPath), "node_modules", "@google", "gemini-cli"), + join(binDir, "node_modules", "@google", "gemini-cli"), + join(dirname(binDir), "node_modules", "@google", "gemini-cli"), + join(dirname(binDir), "lib", "node_modules", "@google", "gemini-cli"), + ]; + + const deduped: string[] = []; + const seen = new Set(); + for (const candidate of candidates) { + const key = + process.platform === "win32" ? candidate.replace(/\\/g, "/").toLowerCase() : candidate; + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(candidate); + } + return deduped; +} + +function findInPath(name: string): string | null { + const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""]; + for (const dir of (process.env.PATH ?? "").split(delimiter)) { + for (const ext of exts) { + const path = join(dir, name + ext); + if (existsSync(path)) { + return path; + } + } + } + return null; +} + +function findFile(dir: string, name: string, depth: number): string | null { + if (depth <= 0) { + return null; + } + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isFile() && entry.name === name) { + return path; + } + if (entry.isDirectory() && !entry.name.startsWith(".")) { + const found = findFile(path, name, depth - 1); + if (found) { + return found; + } + } + } + } catch {} + return null; +} + +export function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } { + const envClientId = resolveEnv(CLIENT_ID_KEYS); + const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS); + if (envClientId) { + return { clientId: envClientId, clientSecret: envClientSecret }; + } + + const extracted = extractGeminiCliCredentials(); + if (extracted) { + return extracted; + } + + throw new Error( + "Gemini CLI not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.", + ); +} diff --git a/extensions/google/oauth.flow.ts b/extensions/google/oauth.flow.ts new file mode 100644 index 00000000000..00cab07dc68 --- /dev/null +++ b/extensions/google/oauth.flow.ts @@ -0,0 +1,152 @@ +import { createHash, randomBytes } from "node:crypto"; +import { createServer } from "node:http"; +import { isWSL2Sync } from "../../src/infra/wsl.js"; +import { resolveOAuthClientConfig } from "./oauth.credentials.js"; +import { AUTH_URL, REDIRECT_URI, SCOPES } from "./oauth.shared.js"; + +export function shouldUseManualOAuthFlow(isRemote: boolean): boolean { + return isRemote || isWSL2Sync(); +} + +export function generatePkce(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +export function buildAuthUrl(challenge: string, verifier: string): string { + const { clientId } = resolveOAuthClientConfig(); + const params = new URLSearchParams({ + client_id: clientId, + response_type: "code", + redirect_uri: REDIRECT_URI, + scope: SCOPES.join(" "), + code_challenge: challenge, + code_challenge_method: "S256", + state: verifier, + access_type: "offline", + prompt: "consent", + }); + return `${AUTH_URL}?${params.toString()}`; +} + +export function parseCallbackInput( + input: string, + expectedState: string, +): { code: string; state: string } | { error: string } { + const trimmed = input.trim(); + if (!trimmed) { + return { error: "No input provided" }; + } + + try { + const url = new URL(trimmed); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state") ?? expectedState; + if (!code) { + return { error: "Missing 'code' parameter in URL" }; + } + if (!state) { + return { error: "Missing 'state' parameter. Paste the full URL." }; + } + return { code, state }; + } catch { + if (!expectedState) { + return { error: "Paste the full redirect URL, not just the code." }; + } + return { code: trimmed, state: expectedState }; + } +} + +export async function waitForLocalCallback(params: { + expectedState: string; + timeoutMs: number; + onProgress?: (message: string) => void; +}): Promise<{ code: string; state: string }> { + const port = 8085; + const hostname = "localhost"; + const expectedPath = "/oauth2callback"; + + return new Promise<{ code: string; state: string }>((resolve, reject) => { + let timeout: NodeJS.Timeout | null = null; + const server = createServer((req, res) => { + try { + const requestUrl = new URL(req.url ?? "/", `http://${hostname}:${port}`); + if (requestUrl.pathname !== expectedPath) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain"); + res.end("Not found"); + return; + } + + const error = requestUrl.searchParams.get("error"); + const code = requestUrl.searchParams.get("code")?.trim(); + const state = requestUrl.searchParams.get("state")?.trim(); + + if (error) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain"); + res.end(`Authentication failed: ${error}`); + finish(new Error(`OAuth error: ${error}`)); + return; + } + + if (!code || !state) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain"); + res.end("Missing code or state"); + finish(new Error("Missing OAuth code or state")); + return; + } + + if (state !== params.expectedState) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain"); + res.end("Invalid state"); + finish(new Error("OAuth state mismatch")); + return; + } + + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end( + "" + + "

Gemini CLI OAuth complete

" + + "

You can close this window and return to OpenClaw.

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

Gemini CLI OAuth complete

" + - "

You can close this window and return to OpenClaw.

", - ); - - finish(undefined, { code, state }); - } catch (err) { - finish(err instanceof Error ? err : new Error("OAuth callback failed")); - } - }); - - const finish = (err?: Error, result?: { code: string; state: string }) => { - if (timeout) { - clearTimeout(timeout); - } - try { - server.close(); - } catch { - // ignore close errors - } - if (err) { - reject(err); - } else if (result) { - resolve(result); - } - }; - - server.once("error", (err) => { - finish(err instanceof Error ? err : new Error("OAuth callback server error")); - }); - - server.listen(port, hostname, () => { - params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}…`); - }); - - timeout = setTimeout(() => { - finish(new Error("OAuth callback timeout")); - }, params.timeoutMs); - }); -} - -async function exchangeCodeForTokens( - code: string, - verifier: string, -): Promise { - const { clientId, clientSecret } = resolveOAuthClientConfig(); - const body = new URLSearchParams({ - client_id: clientId, - code, - grant_type: "authorization_code", - redirect_uri: REDIRECT_URI, - code_verifier: verifier, - }); - if (clientSecret) { - body.set("client_secret", clientSecret); - } - - const response = await fetchWithTimeout(TOKEN_URL, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", - Accept: "*/*", - "User-Agent": "google-api-nodejs-client/9.15.1", - }, - body, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Token exchange failed: ${errorText}`); - } - - const data = (await response.json()) as { - access_token: string; - refresh_token: string; - expires_in: number; - }; - - if (!data.refresh_token) { - throw new Error("No refresh token received. Please try again."); - } - - const email = await getUserEmail(data.access_token); - const projectId = await discoverProject(data.access_token); - const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000; - - return { - refresh: data.refresh_token, - access: data.access_token, - expires: expiresAt, - projectId, - email, - }; -} - -async function getUserEmail(accessToken: string): Promise { - try { - const response = await fetchWithTimeout(USERINFO_URL, { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - if (response.ok) { - const data = (await response.json()) as { email?: string }; - return data.email; - } - } catch { - // ignore - } - return undefined; -} - -async function discoverProject(accessToken: string): Promise { - const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID; - const platform = resolvePlatform(); - const metadata = { - ideType: "ANTIGRAVITY", - platform, - pluginType: "GEMINI", - }; - const headers = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": "google-api-nodejs-client/9.15.1", - "X-Goog-Api-Client": `gl-node/${process.versions.node}`, - "Client-Metadata": JSON.stringify(metadata), - }; - - const loadBody = { - ...(envProject ? { cloudaicompanionProject: envProject } : {}), - metadata: { - ...metadata, - ...(envProject ? { duetProject: envProject } : {}), - }, - }; - - let data: { - currentTier?: { id?: string }; - cloudaicompanionProject?: string | { id?: string }; - allowedTiers?: Array<{ id?: string; isDefault?: boolean }>; - } = {}; - let activeEndpoint = CODE_ASSIST_ENDPOINT_PROD; - let loadError: Error | undefined; - for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) { - try { - const response = await fetchWithTimeout(`${endpoint}/v1internal:loadCodeAssist`, { - method: "POST", - headers, - body: JSON.stringify(loadBody), - }); - - if (!response.ok) { - const errorPayload = await response.json().catch(() => null); - if (isVpcScAffected(errorPayload)) { - data = { currentTier: { id: TIER_STANDARD } }; - activeEndpoint = endpoint; - loadError = undefined; - break; - } - loadError = new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`); - continue; - } - - data = (await response.json()) as typeof data; - activeEndpoint = endpoint; - loadError = undefined; - break; - } catch (err) { - loadError = err instanceof Error ? err : new Error("loadCodeAssist failed", { cause: err }); - } - } - - const hasLoadCodeAssistData = - Boolean(data.currentTier) || - Boolean(data.cloudaicompanionProject) || - Boolean(data.allowedTiers?.length); - if (!hasLoadCodeAssistData && loadError) { - if (envProject) { - return envProject; - } - throw loadError; - } - - if (data.currentTier) { - const project = data.cloudaicompanionProject; - if (typeof project === "string" && project) { - return project; - } - if (typeof project === "object" && project?.id) { - return project.id; - } - if (envProject) { - return envProject; - } - throw new Error( - "This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.", - ); - } - - const tier = getDefaultTier(data.allowedTiers); - const tierId = tier?.id || TIER_FREE; - if (tierId !== TIER_FREE && !envProject) { - throw new Error( - "This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.", - ); - } - - const onboardBody: Record = { - tierId, - metadata: { - ...metadata, - }, - }; - if (tierId !== TIER_FREE && envProject) { - onboardBody.cloudaicompanionProject = envProject; - (onboardBody.metadata as Record).duetProject = envProject; - } - - const onboardResponse = await fetchWithTimeout(`${activeEndpoint}/v1internal:onboardUser`, { - method: "POST", - headers, - body: JSON.stringify(onboardBody), - }); - - if (!onboardResponse.ok) { - throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}`); - } - - let lro = (await onboardResponse.json()) as { - done?: boolean; - name?: string; - response?: { cloudaicompanionProject?: { id?: string } }; - }; - - if (!lro.done && lro.name) { - lro = await pollOperation(activeEndpoint, lro.name, headers); - } - - const projectId = lro.response?.cloudaicompanionProject?.id; - if (projectId) { - return projectId; - } - if (envProject) { - return envProject; - } - - throw new Error( - "Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.", - ); -} - -function isVpcScAffected(payload: unknown): boolean { - if (!payload || typeof payload !== "object") { - return false; - } - const error = (payload as { error?: unknown }).error; - if (!error || typeof error !== "object") { - return false; - } - const details = (error as { details?: unknown[] }).details; - if (!Array.isArray(details)) { - return false; - } - return details.some( - (item) => - typeof item === "object" && - item && - (item as { reason?: string }).reason === "SECURITY_POLICY_VIOLATED", - ); -} - -function getDefaultTier( - allowedTiers?: Array<{ id?: string; isDefault?: boolean }>, -): { id?: string } | undefined { - if (!allowedTiers?.length) { - return { id: TIER_LEGACY }; - } - return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY }; -} - -async function pollOperation( - endpoint: string, - operationName: string, - headers: Record, -): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> { - for (let attempt = 0; attempt < 24; attempt += 1) { - await new Promise((resolve) => setTimeout(resolve, 5000)); - const response = await fetchWithTimeout(`${endpoint}/v1internal/${operationName}`, { - headers, - }); - if (!response.ok) { - continue; - } - const data = (await response.json()) as { - done?: boolean; - response?: { cloudaicompanionProject?: { id?: string } }; - }; - if (data.done) { - return data; - } - } - throw new Error("Operation polling timeout"); -} +import { clearCredentialsCache, extractGeminiCliCredentials } from "./oauth.credentials.js"; +import { + buildAuthUrl, + generatePkce, + parseCallbackInput, + shouldUseManualOAuthFlow, + waitForLocalCallback, +} from "./oauth.flow.js"; +import type { GeminiCliOAuthContext, GeminiCliOAuthCredentials } from "./oauth.shared.js"; +import { exchangeCodeForTokens } from "./oauth.token.js"; + +export { clearCredentialsCache, extractGeminiCliCredentials }; +export type { GeminiCliOAuthContext, GeminiCliOAuthCredentials }; export async function loginGeminiCliOAuth( ctx: GeminiCliOAuthContext, From 59940cb3ee30f1f3c6d5a81a7d1decb694b13c50 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:32:47 +0000 Subject: [PATCH 113/943] refactor(plugin-sdk): centralize entrypoint manifest --- package.json | 1 + scripts/check-plugin-sdk-exports.mjs | 48 +-------------- scripts/lib/plugin-sdk-entries.mjs | 78 ++++++++++++++++++++++++ scripts/release-check.ts | 88 +-------------------------- scripts/sync-plugin-sdk-exports.mjs | 34 +++++++++++ scripts/write-plugin-sdk-entry-dts.ts | 48 +-------------- src/plugin-sdk/index.test.ts | 83 ++++++------------------- src/plugin-sdk/subpaths.test.ts | 42 +++---------- tsconfig.plugin-sdk.dts.json | 47 +------------- tsdown.config.ts | 49 +-------------- vitest.config.ts | 46 +------------- 11 files changed, 150 insertions(+), 414 deletions(-) create mode 100644 scripts/lib/plugin-sdk-entries.mjs create mode 100644 scripts/sync-plugin-sdk-exports.mjs diff --git a/package.json b/package.json index 86822b23bf1..cc6925725fa 100644 --- a/package.json +++ b/package.json @@ -292,6 +292,7 @@ "moltbot:rpc": "node scripts/run-node.mjs agent --mode rpc --json", "openclaw": "node scripts/run-node.mjs", "openclaw:rpc": "node scripts/run-node.mjs agent --mode rpc --json", + "plugin-sdk:sync-exports": "node scripts/sync-plugin-sdk-exports.mjs", "plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts", "prepack": "pnpm build && pnpm ui:build", "prepare": "command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1 && git config core.hooksPath git-hooks || exit 0", diff --git a/scripts/check-plugin-sdk-exports.mjs b/scripts/check-plugin-sdk-exports.mjs index 93fc3fcb545..60c89056ca0 100755 --- a/scripts/check-plugin-sdk-exports.mjs +++ b/scripts/check-plugin-sdk-exports.mjs @@ -11,6 +11,7 @@ import { readFileSync, existsSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { pluginSdkSubpaths } from "./lib/plugin-sdk-entries.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const distFile = resolve(__dirname, "..", "dist", "plugin-sdk", "index.js"); @@ -41,51 +42,6 @@ const exportedNames = exportMatch[1] const exportSet = new Set(exportedNames); -const requiredSubpathEntries = [ - "core", - "compat", - "telegram", - "discord", - "slack", - "signal", - "imessage", - "whatsapp", - "line", - "msteams", - "acpx", - "bluebubbles", - "copilot-proxy", - "device-pair", - "diagnostics-otel", - "diffs", - "feishu", - "googlechat", - "irc", - "llm-task", - "lobster", - "matrix", - "mattermost", - "memory-core", - "memory-lancedb", - "minimax-portal-auth", - "nextcloud-talk", - "nostr", - "open-prose", - "phone-control", - "qwen-portal-auth", - "synology-chat", - "talk-voice", - "test-utils", - "thread-ownership", - "tlon", - "twitch", - "voice-call", - "zalo", - "zalouser", - "account-id", - "keyed-async-queue", -]; - const requiredRuntimeShimEntries = ["root-alias.cjs"]; // Critical functions that channel extension plugins import from openclaw/plugin-sdk. @@ -123,7 +79,7 @@ for (const name of requiredExports) { } } -for (const entry of requiredSubpathEntries) { +for (const entry of pluginSdkSubpaths) { const jsPath = resolve(__dirname, "..", "dist", "plugin-sdk", `${entry}.js`); const dtsPath = resolve(__dirname, "..", "dist", "plugin-sdk", `${entry}.d.ts`); if (!existsSync(jsPath)) { diff --git a/scripts/lib/plugin-sdk-entries.mjs b/scripts/lib/plugin-sdk-entries.mjs new file mode 100644 index 00000000000..ba6c1a5c386 --- /dev/null +++ b/scripts/lib/plugin-sdk-entries.mjs @@ -0,0 +1,78 @@ +export const pluginSdkEntrypoints = [ + "index", + "core", + "compat", + "telegram", + "discord", + "slack", + "signal", + "imessage", + "whatsapp", + "line", + "msteams", + "acpx", + "bluebubbles", + "copilot-proxy", + "device-pair", + "diagnostics-otel", + "diffs", + "feishu", + "googlechat", + "irc", + "llm-task", + "lobster", + "matrix", + "mattermost", + "memory-core", + "memory-lancedb", + "minimax-portal-auth", + "nextcloud-talk", + "nostr", + "open-prose", + "phone-control", + "qwen-portal-auth", + "synology-chat", + "talk-voice", + "test-utils", + "thread-ownership", + "tlon", + "twitch", + "voice-call", + "zalo", + "zalouser", + "account-id", + "keyed-async-queue", +]; + +export const pluginSdkSubpaths = pluginSdkEntrypoints.filter((entry) => entry !== "index"); + +export function buildPluginSdkEntrySources() { + return Object.fromEntries( + pluginSdkEntrypoints.map((entry) => [entry, `src/plugin-sdk/${entry}.ts`]), + ); +} + +export function buildPluginSdkSpecifiers() { + return pluginSdkEntrypoints.map((entry) => + entry === "index" ? "openclaw/plugin-sdk" : `openclaw/plugin-sdk/${entry}`, + ); +} + +export function buildPluginSdkPackageExports() { + return Object.fromEntries( + pluginSdkEntrypoints.map((entry) => [ + entry === "index" ? "./plugin-sdk" : `./plugin-sdk/${entry}`, + { + types: `./dist/plugin-sdk/${entry}.d.ts`, + default: `./dist/plugin-sdk/${entry}.js`, + }, + ]), + ); +} + +export function listPluginSdkDistArtifacts() { + return pluginSdkEntrypoints.flatMap((entry) => [ + `dist/plugin-sdk/${entry}.js`, + `dist/plugin-sdk/${entry}.d.ts`, + ]); +} diff --git a/scripts/release-check.ts b/scripts/release-check.ts index b8e4fa6706b..7eedc970103 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -10,6 +10,7 @@ import { type BundledExtension, type ExtensionPackageJson as PackageJson, } from "./lib/bundled-extension-manifest.ts"; +import { listPluginSdkDistArtifacts } from "./lib/plugin-sdk-entries.mjs"; import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./sparkle-build.ts"; export { collectBundledExtensionManifestErrors } from "./lib/bundled-extension-manifest.ts"; @@ -20,93 +21,8 @@ type PackResult = { files?: PackFile[]; filename?: string; unpackedSize?: number const requiredPathGroups = [ ["dist/index.js", "dist/index.mjs"], ["dist/entry.js", "dist/entry.mjs"], - "dist/plugin-sdk/index.js", - "dist/plugin-sdk/index.d.ts", - "dist/plugin-sdk/core.js", - "dist/plugin-sdk/core.d.ts", + ...listPluginSdkDistArtifacts(), "dist/plugin-sdk/root-alias.cjs", - "dist/plugin-sdk/compat.js", - "dist/plugin-sdk/compat.d.ts", - "dist/plugin-sdk/telegram.js", - "dist/plugin-sdk/telegram.d.ts", - "dist/plugin-sdk/discord.js", - "dist/plugin-sdk/discord.d.ts", - "dist/plugin-sdk/slack.js", - "dist/plugin-sdk/slack.d.ts", - "dist/plugin-sdk/signal.js", - "dist/plugin-sdk/signal.d.ts", - "dist/plugin-sdk/imessage.js", - "dist/plugin-sdk/imessage.d.ts", - "dist/plugin-sdk/whatsapp.js", - "dist/plugin-sdk/whatsapp.d.ts", - "dist/plugin-sdk/line.js", - "dist/plugin-sdk/line.d.ts", - "dist/plugin-sdk/msteams.js", - "dist/plugin-sdk/msteams.d.ts", - "dist/plugin-sdk/acpx.js", - "dist/plugin-sdk/acpx.d.ts", - "dist/plugin-sdk/bluebubbles.js", - "dist/plugin-sdk/bluebubbles.d.ts", - "dist/plugin-sdk/copilot-proxy.js", - "dist/plugin-sdk/copilot-proxy.d.ts", - "dist/plugin-sdk/device-pair.js", - "dist/plugin-sdk/device-pair.d.ts", - "dist/plugin-sdk/diagnostics-otel.js", - "dist/plugin-sdk/diagnostics-otel.d.ts", - "dist/plugin-sdk/diffs.js", - "dist/plugin-sdk/diffs.d.ts", - "dist/plugin-sdk/feishu.js", - "dist/plugin-sdk/feishu.d.ts", - "dist/plugin-sdk/googlechat.js", - "dist/plugin-sdk/googlechat.d.ts", - "dist/plugin-sdk/irc.js", - "dist/plugin-sdk/irc.d.ts", - "dist/plugin-sdk/llm-task.js", - "dist/plugin-sdk/llm-task.d.ts", - "dist/plugin-sdk/lobster.js", - "dist/plugin-sdk/lobster.d.ts", - "dist/plugin-sdk/matrix.js", - "dist/plugin-sdk/matrix.d.ts", - "dist/plugin-sdk/mattermost.js", - "dist/plugin-sdk/mattermost.d.ts", - "dist/plugin-sdk/memory-core.js", - "dist/plugin-sdk/memory-core.d.ts", - "dist/plugin-sdk/memory-lancedb.js", - "dist/plugin-sdk/memory-lancedb.d.ts", - "dist/plugin-sdk/minimax-portal-auth.js", - "dist/plugin-sdk/minimax-portal-auth.d.ts", - "dist/plugin-sdk/nextcloud-talk.js", - "dist/plugin-sdk/nextcloud-talk.d.ts", - "dist/plugin-sdk/nostr.js", - "dist/plugin-sdk/nostr.d.ts", - "dist/plugin-sdk/open-prose.js", - "dist/plugin-sdk/open-prose.d.ts", - "dist/plugin-sdk/phone-control.js", - "dist/plugin-sdk/phone-control.d.ts", - "dist/plugin-sdk/qwen-portal-auth.js", - "dist/plugin-sdk/qwen-portal-auth.d.ts", - "dist/plugin-sdk/synology-chat.js", - "dist/plugin-sdk/synology-chat.d.ts", - "dist/plugin-sdk/talk-voice.js", - "dist/plugin-sdk/talk-voice.d.ts", - "dist/plugin-sdk/test-utils.js", - "dist/plugin-sdk/test-utils.d.ts", - "dist/plugin-sdk/thread-ownership.js", - "dist/plugin-sdk/thread-ownership.d.ts", - "dist/plugin-sdk/tlon.js", - "dist/plugin-sdk/tlon.d.ts", - "dist/plugin-sdk/twitch.js", - "dist/plugin-sdk/twitch.d.ts", - "dist/plugin-sdk/voice-call.js", - "dist/plugin-sdk/voice-call.d.ts", - "dist/plugin-sdk/zalo.js", - "dist/plugin-sdk/zalo.d.ts", - "dist/plugin-sdk/zalouser.js", - "dist/plugin-sdk/zalouser.d.ts", - "dist/plugin-sdk/account-id.js", - "dist/plugin-sdk/account-id.d.ts", - "dist/plugin-sdk/keyed-async-queue.js", - "dist/plugin-sdk/keyed-async-queue.d.ts", "dist/build-info.json", ]; const forbiddenPrefixes = ["dist/OpenClaw.app/"]; diff --git a/scripts/sync-plugin-sdk-exports.mjs b/scripts/sync-plugin-sdk-exports.mjs new file mode 100644 index 00000000000..cfe2e181259 --- /dev/null +++ b/scripts/sync-plugin-sdk-exports.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { buildPluginSdkPackageExports } from "./lib/plugin-sdk-entries.mjs"; + +const packageJsonPath = path.join(process.cwd(), "package.json"); +const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); +const currentExports = packageJson.exports ?? {}; +const syncedPluginSdkExports = buildPluginSdkPackageExports(); + +const nextExports = {}; +let insertedPluginSdkExports = false; +for (const [key, value] of Object.entries(currentExports)) { + if (key.startsWith("./plugin-sdk")) { + if (!insertedPluginSdkExports) { + Object.assign(nextExports, syncedPluginSdkExports); + insertedPluginSdkExports = true; + } + continue; + } + nextExports[key] = value; + if (key === "." && !insertedPluginSdkExports) { + Object.assign(nextExports, syncedPluginSdkExports); + insertedPluginSdkExports = true; + } +} + +if (!insertedPluginSdkExports) { + Object.assign(nextExports, syncedPluginSdkExports); +} + +packageJson.exports = nextExports; +fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8"); diff --git a/scripts/write-plugin-sdk-entry-dts.ts b/scripts/write-plugin-sdk-entry-dts.ts index d0331377432..832368bbcd3 100644 --- a/scripts/write-plugin-sdk-entry-dts.ts +++ b/scripts/write-plugin-sdk-entry-dts.ts @@ -1,57 +1,13 @@ import fs from "node:fs"; import path from "node:path"; +import { pluginSdkEntrypoints } from "./lib/plugin-sdk-entries.mjs"; // `tsc` emits declarations under `dist/plugin-sdk/src/plugin-sdk/*` because the source lives // at `src/plugin-sdk/*` and `rootDir` is `.` (repo root, to support cross-src/extensions refs). // // Our package export map points subpath `types` at `dist/plugin-sdk/.d.ts`, so we // generate stable entry d.ts files that re-export the real declarations. -const entrypoints = [ - "index", - "core", - "compat", - "telegram", - "discord", - "slack", - "signal", - "imessage", - "whatsapp", - "line", - "msteams", - "acpx", - "bluebubbles", - "copilot-proxy", - "device-pair", - "diagnostics-otel", - "diffs", - "feishu", - "googlechat", - "irc", - "llm-task", - "lobster", - "matrix", - "mattermost", - "memory-core", - "memory-lancedb", - "minimax-portal-auth", - "nextcloud-talk", - "nostr", - "open-prose", - "phone-control", - "qwen-portal-auth", - "synology-chat", - "talk-voice", - "test-utils", - "thread-ownership", - "tlon", - "twitch", - "voice-call", - "zalo", - "zalouser", - "account-id", - "keyed-async-queue", -] as const; -for (const entry of entrypoints) { +for (const entry of pluginSdkEntrypoints) { const out = path.join(process.cwd(), `dist/plugin-sdk/${entry}.d.ts`); fs.mkdirSync(path.dirname(out), { recursive: true }); // NodeNext: reference the runtime specifier with `.js`, TS will map it to `.d.ts`. diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts index 8fe13972e11..4e9a8869849 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.ts @@ -4,68 +4,15 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { build } from "tsdown"; import { describe, expect, it } from "vitest"; +import { + buildPluginSdkEntrySources, + buildPluginSdkPackageExports, + buildPluginSdkSpecifiers, + pluginSdkEntrypoints, +} from "../../scripts/lib/plugin-sdk-entries.mjs"; import * as sdk from "./index.js"; -const pluginSdkEntrypoints = [ - "index", - "core", - "compat", - "telegram", - "discord", - "slack", - "signal", - "imessage", - "whatsapp", - "line", - "msteams", - "acpx", - "bluebubbles", - "copilot-proxy", - "device-pair", - "diagnostics-otel", - "diffs", - "feishu", - "googlechat", - "irc", - "llm-task", - "lobster", - "matrix", - "mattermost", - "memory-core", - "memory-lancedb", - "minimax-portal-auth", - "nextcloud-talk", - "nostr", - "open-prose", - "phone-control", - "qwen-portal-auth", - "synology-chat", - "talk-voice", - "test-utils", - "thread-ownership", - "tlon", - "twitch", - "voice-call", - "zalo", - "zalouser", - "account-id", - "keyed-async-queue", -] as const; - -const pluginSdkSpecifiers = pluginSdkEntrypoints.map((entry) => - entry === "index" ? "openclaw/plugin-sdk" : `openclaw/plugin-sdk/${entry}`, -); - -function buildPluginSdkPackageExports() { - return Object.fromEntries( - pluginSdkEntrypoints.map((entry) => [ - entry === "index" ? "./plugin-sdk" : `./plugin-sdk/${entry}`, - { - default: `./dist/plugin-sdk/${entry}.js`, - }, - ]), - ); -} +const pluginSdkSpecifiers = buildPluginSdkSpecifiers(); describe("plugin-sdk exports", () => { it("does not expose runtime modules", () => { @@ -180,9 +127,7 @@ describe("plugin-sdk exports", () => { clean: true, config: false, dts: false, - entry: Object.fromEntries( - pluginSdkEntrypoints.map((entry) => [entry, `src/plugin-sdk/${entry}.ts`]), - ), + entry: buildPluginSdkEntrySources(), env: { NODE_ENV: "production" }, fixedExtension: false, logLevel: "error", @@ -237,4 +182,16 @@ describe("plugin-sdk exports", () => { await fs.rm(fixtureDir, { recursive: true, force: true }); } }); + + it("keeps package.json plugin-sdk exports synced with the manifest", async () => { + const packageJsonPath = path.join(process.cwd(), "package.json"); + const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8")) as { + exports?: Record; + }; + const currentPluginSdkExports = Object.fromEntries( + Object.entries(packageJson.exports ?? {}).filter(([key]) => key.startsWith("./plugin-sdk")), + ); + + expect(currentPluginSdkExports).toEqual(buildPluginSdkPackageExports()); + }); }); diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 42d69512925..6b696be7269 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -9,42 +9,14 @@ import * as slackSdk from "openclaw/plugin-sdk/slack"; import * as telegramSdk from "openclaw/plugin-sdk/telegram"; import * as whatsappSdk from "openclaw/plugin-sdk/whatsapp"; import { describe, expect, it } from "vitest"; +import { pluginSdkSubpaths } from "../../scripts/lib/plugin-sdk-entries.mjs"; -const bundledExtensionSubpathLoaders = [ - { id: "acpx", load: () => import("openclaw/plugin-sdk/acpx") }, - { id: "bluebubbles", load: () => import("openclaw/plugin-sdk/bluebubbles") }, - { id: "copilot-proxy", load: () => import("openclaw/plugin-sdk/copilot-proxy") }, - { id: "device-pair", load: () => import("openclaw/plugin-sdk/device-pair") }, - { id: "diagnostics-otel", load: () => import("openclaw/plugin-sdk/diagnostics-otel") }, - { id: "diffs", load: () => import("openclaw/plugin-sdk/diffs") }, - { id: "feishu", load: () => import("openclaw/plugin-sdk/feishu") }, - { id: "googlechat", load: () => import("openclaw/plugin-sdk/googlechat") }, - { id: "irc", load: () => import("openclaw/plugin-sdk/irc") }, - { id: "llm-task", load: () => import("openclaw/plugin-sdk/llm-task") }, - { id: "lobster", load: () => import("openclaw/plugin-sdk/lobster") }, - { id: "matrix", load: () => import("openclaw/plugin-sdk/matrix") }, - { id: "mattermost", load: () => import("openclaw/plugin-sdk/mattermost") }, - { id: "memory-core", load: () => import("openclaw/plugin-sdk/memory-core") }, - { id: "memory-lancedb", load: () => import("openclaw/plugin-sdk/memory-lancedb") }, - { - id: "minimax-portal-auth", - load: () => import("openclaw/plugin-sdk/minimax-portal-auth"), - }, - { id: "nextcloud-talk", load: () => import("openclaw/plugin-sdk/nextcloud-talk") }, - { id: "nostr", load: () => import("openclaw/plugin-sdk/nostr") }, - { id: "open-prose", load: () => import("openclaw/plugin-sdk/open-prose") }, - { id: "phone-control", load: () => import("openclaw/plugin-sdk/phone-control") }, - { id: "qwen-portal-auth", load: () => import("openclaw/plugin-sdk/qwen-portal-auth") }, - { id: "synology-chat", load: () => import("openclaw/plugin-sdk/synology-chat") }, - { id: "talk-voice", load: () => import("openclaw/plugin-sdk/talk-voice") }, - { id: "test-utils", load: () => import("openclaw/plugin-sdk/test-utils") }, - { id: "thread-ownership", load: () => import("openclaw/plugin-sdk/thread-ownership") }, - { id: "tlon", load: () => import("openclaw/plugin-sdk/tlon") }, - { id: "twitch", load: () => import("openclaw/plugin-sdk/twitch") }, - { id: "voice-call", load: () => import("openclaw/plugin-sdk/voice-call") }, - { id: "zalo", load: () => import("openclaw/plugin-sdk/zalo") }, - { id: "zalouser", load: () => import("openclaw/plugin-sdk/zalouser") }, -] as const; +const importPluginSdkSubpath = (specifier: string) => import(/* @vite-ignore */ specifier); + +const bundledExtensionSubpathLoaders = pluginSdkSubpaths.map((id) => ({ + id, + load: () => importPluginSdkSubpath(`openclaw/plugin-sdk/${id}`), +})); describe("plugin-sdk subpath exports", () => { it("exports compat helpers", () => { diff --git a/tsconfig.plugin-sdk.dts.json b/tsconfig.plugin-sdk.dts.json index 15828b8b7ad..b182b3e30e4 100644 --- a/tsconfig.plugin-sdk.dts.json +++ b/tsconfig.plugin-sdk.dts.json @@ -10,51 +10,6 @@ "rootDir": ".", "tsBuildInfoFile": "dist/plugin-sdk/.tsbuildinfo" }, - "include": [ - "src/plugin-sdk/index.ts", - "src/plugin-sdk/core.ts", - "src/plugin-sdk/compat.ts", - "src/plugin-sdk/telegram.ts", - "src/plugin-sdk/discord.ts", - "src/plugin-sdk/slack.ts", - "src/plugin-sdk/signal.ts", - "src/plugin-sdk/imessage.ts", - "src/plugin-sdk/whatsapp.ts", - "src/plugin-sdk/line.ts", - "src/plugin-sdk/msteams.ts", - "src/plugin-sdk/account-id.ts", - "src/plugin-sdk/keyed-async-queue.ts", - "src/plugin-sdk/acpx.ts", - "src/plugin-sdk/bluebubbles.ts", - "src/plugin-sdk/copilot-proxy.ts", - "src/plugin-sdk/device-pair.ts", - "src/plugin-sdk/diagnostics-otel.ts", - "src/plugin-sdk/diffs.ts", - "src/plugin-sdk/feishu.ts", - "src/plugin-sdk/googlechat.ts", - "src/plugin-sdk/irc.ts", - "src/plugin-sdk/llm-task.ts", - "src/plugin-sdk/lobster.ts", - "src/plugin-sdk/matrix.ts", - "src/plugin-sdk/mattermost.ts", - "src/plugin-sdk/memory-core.ts", - "src/plugin-sdk/memory-lancedb.ts", - "src/plugin-sdk/minimax-portal-auth.ts", - "src/plugin-sdk/nextcloud-talk.ts", - "src/plugin-sdk/nostr.ts", - "src/plugin-sdk/open-prose.ts", - "src/plugin-sdk/phone-control.ts", - "src/plugin-sdk/qwen-portal-auth.ts", - "src/plugin-sdk/synology-chat.ts", - "src/plugin-sdk/talk-voice.ts", - "src/plugin-sdk/test-utils.ts", - "src/plugin-sdk/thread-ownership.ts", - "src/plugin-sdk/tlon.ts", - "src/plugin-sdk/twitch.ts", - "src/plugin-sdk/voice-call.ts", - "src/plugin-sdk/zalo.ts", - "src/plugin-sdk/zalouser.ts", - "src/types/**/*.d.ts" - ], + "include": ["src/plugin-sdk/**/*.ts", "src/types/**/*.d.ts"], "exclude": ["node_modules", "dist", "src/**/*.test.ts"] } diff --git a/tsdown.config.ts b/tsdown.config.ts index b266f660421..80eaae39a4e 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { defineConfig } from "tsdown"; +import { buildPluginSdkEntrySources } from "./scripts/lib/plugin-sdk-entries.mjs"; const env = { NODE_ENV: "production", @@ -58,52 +59,6 @@ function nodeBuildConfig(config: Record) { }; } -const pluginSdkEntrypoints = [ - "index", - "core", - "compat", - "telegram", - "discord", - "slack", - "signal", - "imessage", - "whatsapp", - "line", - "msteams", - "acpx", - "bluebubbles", - "copilot-proxy", - "device-pair", - "diagnostics-otel", - "diffs", - "feishu", - "googlechat", - "irc", - "llm-task", - "lobster", - "matrix", - "mattermost", - "memory-core", - "memory-lancedb", - "minimax-portal-auth", - "nextcloud-talk", - "nostr", - "open-prose", - "phone-control", - "qwen-portal-auth", - "synology-chat", - "talk-voice", - "test-utils", - "thread-ownership", - "tlon", - "twitch", - "voice-call", - "zalo", - "zalouser", - "account-id", - "keyed-async-queue", -] as const; - function listBundledPluginBuildEntries(): Record { const extensionsRoot = path.join(process.cwd(), "extensions"); const entries: Record = {}; @@ -189,7 +144,7 @@ export default defineConfig([ nodeBuildConfig({ // Bundle all plugin-sdk entries in a single build so the bundler can share // common chunks instead of duplicating them per entry (~712MB heap saved). - entry: Object.fromEntries(pluginSdkEntrypoints.map((e) => [e, `src/plugin-sdk/${e}.ts`])), + entry: buildPluginSdkEntrySources(), outDir: "dist/plugin-sdk", }), nodeBuildConfig({ diff --git a/vitest.config.ts b/vitest.config.ts index c45f5f45c25..564065be9e3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,57 +2,13 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; +import { pluginSdkSubpaths } from "./scripts/lib/plugin-sdk-entries.mjs"; const repoRoot = path.dirname(fileURLToPath(import.meta.url)); const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; const isWindows = process.platform === "win32"; const localWorkers = Math.max(4, Math.min(16, os.cpus().length)); const ciWorkers = isWindows ? 2 : 3; -const pluginSdkSubpaths = [ - "account-id", - "core", - "compat", - "telegram", - "discord", - "slack", - "signal", - "imessage", - "whatsapp", - "line", - "msteams", - "acpx", - "bluebubbles", - "copilot-proxy", - "device-pair", - "diagnostics-otel", - "diffs", - "feishu", - "googlechat", - "irc", - "llm-task", - "lobster", - "matrix", - "mattermost", - "memory-core", - "memory-lancedb", - "minimax-portal-auth", - "nextcloud-talk", - "nostr", - "open-prose", - "phone-control", - "qwen-portal-auth", - "synology-chat", - "talk-voice", - "test-utils", - "thread-ownership", - "tlon", - "twitch", - "voice-call", - "zalo", - "zalouser", - "keyed-async-queue", -] as const; - export default defineConfig({ resolve: { // Keep this ordered: the base `openclaw/plugin-sdk` alias is a prefix match. From 0a136f1b906b09f725cc6ffb0211ceffcc8b9b05 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:42:13 +0000 Subject: [PATCH 114/943] fix(docs): harden i18n prompt failures --- scripts/docs-i18n/translator.go | 97 +++++----------------------- scripts/docs-i18n/translator_test.go | 92 ++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 81 deletions(-) create mode 100644 scripts/docs-i18n/translator_test.go diff --git a/scripts/docs-i18n/translator.go b/scripts/docs-i18n/translator.go index aac2afc5f80..8f7023c615b 100644 --- a/scripts/docs-i18n/translator.go +++ b/scripts/docs-i18n/translator.go @@ -2,7 +2,6 @@ package main import ( "context" - "encoding/json" "errors" "fmt" "strings" @@ -14,6 +13,7 @@ import ( const ( translateMaxAttempts = 3 translateBaseDelay = 15 * time.Second + translatePromptTimeout = 2 * time.Minute ) var errEmptyTranslation = errors.New("empty translation") @@ -145,96 +145,31 @@ func (t *PiTranslator) Close() { } } -type agentEndPayload struct { - Messages []agentMessage `json:"messages"` +type promptRunner interface { + Run(context.Context, string) (pi.RunResult, error) + Stderr() string } -type agentMessage struct { - Role string `json:"role"` - Content json.RawMessage `json:"content"` - StopReason string `json:"stopReason,omitempty"` - ErrorMessage string `json:"errorMessage,omitempty"` -} - -type contentBlock struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` -} - -func runPrompt(ctx context.Context, client *pi.OneShotClient, message string) (string, error) { - events, cancel := client.Subscribe(256) +func runPrompt(ctx context.Context, client promptRunner, message string) (string, error) { + promptCtx, cancel := context.WithTimeout(ctx, translatePromptTimeout) defer cancel() - if err := client.Prompt(ctx, message); err != nil { - return "", err - } - - for { - select { - case <-ctx.Done(): - return "", ctx.Err() - case event, ok := <-events: - if !ok { - return "", errors.New("event stream closed") - } - if event.Type == "agent_end" { - return extractTranslationResult(event.Raw) - } - } + result, err := client.Run(promptCtx, message) + if err != nil { + return "", decoratePromptError(err, client.Stderr()) } + return result.Text, nil } -func extractTranslationResult(raw json.RawMessage) (string, error) { - var payload agentEndPayload - if err := json.Unmarshal(raw, &payload); err != nil { - return "", err +func decoratePromptError(err error, stderr string) error { + if err == nil { + return nil } - for index := len(payload.Messages) - 1; index >= 0; index-- { - message := payload.Messages[index] - if message.Role != "assistant" { - continue - } - if message.ErrorMessage != "" || strings.EqualFold(message.StopReason, "error") { - msg := strings.TrimSpace(message.ErrorMessage) - if msg == "" { - msg = "unknown error" - } - return "", fmt.Errorf("pi error: %s", msg) - } - text, err := extractContentText(message.Content) - if err != nil { - return "", err - } - return text, nil - } - return "", errors.New("assistant message not found") -} - -func extractContentText(content json.RawMessage) (string, error) { - trimmed := strings.TrimSpace(string(content)) + trimmed := strings.TrimSpace(stderr) if trimmed == "" { - return "", nil + return err } - if strings.HasPrefix(trimmed, "\"") { - var text string - if err := json.Unmarshal(content, &text); err != nil { - return "", err - } - return text, nil - } - - var blocks []contentBlock - if err := json.Unmarshal(content, &blocks); err != nil { - return "", err - } - - var parts []string - for _, block := range blocks { - if block.Type == "text" && block.Text != "" { - parts = append(parts, block.Text) - } - } - return strings.Join(parts, ""), nil + return fmt.Errorf("%w (pi stderr: %s)", err, trimmed) } func normalizeThinking(value string) string { diff --git a/scripts/docs-i18n/translator_test.go b/scripts/docs-i18n/translator_test.go new file mode 100644 index 00000000000..a632e44e96e --- /dev/null +++ b/scripts/docs-i18n/translator_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + pi "github.com/joshp123/pi-golang" +) + +type fakePromptRunner struct { + run func(context.Context, string) (pi.RunResult, error) + stderr string +} + +func (runner fakePromptRunner) Run(ctx context.Context, message string) (pi.RunResult, error) { + return runner.run(ctx, message) +} + +func (runner fakePromptRunner) Stderr() string { + return runner.stderr +} + +func TestRunPromptAddsTimeout(t *testing.T) { + t.Parallel() + + var deadline time.Time + client := fakePromptRunner{ + run: func(ctx context.Context, message string) (pi.RunResult, error) { + var ok bool + deadline, ok = ctx.Deadline() + if !ok { + t.Fatal("expected prompt deadline") + } + if message != "Translate me" { + t.Fatalf("unexpected message %q", message) + } + return pi.RunResult{Text: "translated"}, nil + }, + } + + got, err := runPrompt(context.Background(), client, "Translate me") + if err != nil { + t.Fatalf("runPrompt returned error: %v", err) + } + if got != "translated" { + t.Fatalf("unexpected translation %q", got) + } + + remaining := time.Until(deadline) + if remaining <= time.Minute || remaining > translatePromptTimeout { + t.Fatalf("unexpected timeout window %s", remaining) + } +} + +func TestRunPromptIncludesStderr(t *testing.T) { + t.Parallel() + + rootErr := errors.New("context deadline exceeded") + client := fakePromptRunner{ + run: func(context.Context, string) (pi.RunResult, error) { + return pi.RunResult{}, rootErr + }, + stderr: "boom", + } + + _, err := runPrompt(context.Background(), client, "Translate me") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, rootErr) { + t.Fatalf("expected wrapped root error, got %v", err) + } + if !strings.Contains(err.Error(), "pi stderr: boom") { + t.Fatalf("expected stderr in error, got %v", err) + } +} + +func TestDecoratePromptErrorLeavesCleanErrorsAlone(t *testing.T) { + t.Parallel() + + rootErr := errors.New("plain failure") + got := decoratePromptError(rootErr, " ") + if !errors.Is(got, rootErr) { + t.Fatalf("expected original error, got %v", got) + } + if got.Error() != rootErr.Error() { + t.Fatalf("expected unchanged message, got %v", got) + } +} From 6987a3c8b57e650c4236bd87a5b7b0f3e94aed6b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 01:42:45 +0000 Subject: [PATCH 115/943] docs(i18n): sync zh-CN google plugin references --- docs/zh-CN/concepts/model-providers.md | 18 ++++++++---------- docs/zh-CN/help/faq.md | 6 +++--- docs/zh-CN/tools/plugin.md | 7 +++---- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/docs/zh-CN/concepts/model-providers.md b/docs/zh-CN/concepts/model-providers.md index e55eb7d0e45..ba345d18743 100644 --- a/docs/zh-CN/concepts/model-providers.md +++ b/docs/zh-CN/concepts/model-providers.md @@ -5,10 +5,10 @@ read_when: summary: 模型提供商概述,包含示例配置和 CLI 流程 title: 模型提供商 x-i18n: - generated_at: "2026-02-03T07:46:28Z" + generated_at: "2026-03-16T01:39:16Z" model: claude-opus-4-5 provider: pi - source_hash: 14f73e5a9f9b7c6f017d59a54633942dba95a3eb50f8848b836cfe0b9f6d7719 + source_hash: 978798c80c5809c162f9807072ab48fdf99bfe0db39b2b3c245ce8b4e5451603 source_path: concepts/model-providers.md workflow: 15 --- @@ -87,15 +87,13 @@ OpenClaw 附带 pi-ai 目录。这些提供商**不需要** `models.providers` - 示例模型:`google/gemini-3-pro-preview` - CLI:`openclaw onboard --auth-choice gemini-api-key` -### Google Vertex、Antigravity 和 Gemini CLI +### Google Vertex 和 Gemini CLI -- 提供商:`google-vertex`、`google-antigravity`、`google-gemini-cli` -- 认证:Vertex 使用 gcloud ADC;Antigravity/Gemini CLI 使用各自的认证流程 -- Antigravity OAuth 作为捆绑插件提供(`google-antigravity-auth`,默认禁用)。 - - 启用:`openclaw plugins enable google-antigravity-auth` - - 登录:`openclaw models auth login --provider google-antigravity --set-default` -- Gemini CLI OAuth 作为捆绑插件提供(`google-gemini-cli-auth`,默认禁用)。 - - 启用:`openclaw plugins enable google-gemini-cli-auth` +- 提供商:`google-vertex`、`google-gemini-cli` +- 认证:Vertex 使用 gcloud ADC;Gemini CLI 使用其 OAuth 流程 +- 注意:OpenClaw 中的 Gemini CLI OAuth 属于非官方集成。一些用户报告称,在第三方客户端中使用后其 Google 账号受到了限制。继续前请先查看 Google 条款,并尽量使用非关键账号。 +- Gemini CLI OAuth 作为捆绑 `google` 插件的一部分提供。 + - 启用:`openclaw plugins enable google` - 登录:`openclaw models auth login --provider google-gemini-cli --set-default` - 注意:你**不需要**将客户端 ID 或密钥粘贴到 `openclaw.json` 中。CLI 登录流程将令牌存储在 Gateway 网关主机的认证配置文件中。 diff --git a/docs/zh-CN/help/faq.md b/docs/zh-CN/help/faq.md index 3d9742c2b28..feb6aea4341 100644 --- a/docs/zh-CN/help/faq.md +++ b/docs/zh-CN/help/faq.md @@ -2,10 +2,10 @@ summary: 关于 OpenClaw 安装、配置和使用的常见问题 title: 常见问题 x-i18n: - generated_at: "2026-02-01T21:32:04Z" + generated_at: "2026-03-16T01:39:16Z" model: claude-opus-4-5 provider: pi - source_hash: 5a611f2fda3325b1c7a9ec518616d87c78be41e2bfbe86244ae4f48af3815a26 + source_hash: 6e6a4a63fb73dca24dbe77928b51c6b2e5d51ec883fb36c64e2e40ef027050e9 source_path: help/faq.md workflow: 15 --- @@ -687,7 +687,7 @@ Gemini CLI 使用**插件认证流程**,而不是 `openclaw.json` 中的 clien 步骤: -1. 启用插件:`openclaw plugins enable google-gemini-cli-auth` +1. 启用插件:`openclaw plugins enable google` 2. 登录:`openclaw models auth login --provider google-gemini-cli --set-default` 这会在 Gateway 网关主机上将 OAuth 令牌存储为认证配置文件。详情:[模型提供商](/concepts/model-providers)。 diff --git a/docs/zh-CN/tools/plugin.md b/docs/zh-CN/tools/plugin.md index fde337fc3a4..5ec0b9707ff 100644 --- a/docs/zh-CN/tools/plugin.md +++ b/docs/zh-CN/tools/plugin.md @@ -5,10 +5,10 @@ read_when: summary: OpenClaw 插件/扩展:发现、配置和安全 title: 插件 x-i18n: - generated_at: "2026-02-03T07:55:25Z" + generated_at: "2026-03-16T01:39:16Z" model: claude-opus-4-5 provider: pi - source_hash: b36ca6b90ca03eaae25c00f9b12f2717fcd17ac540ba616ee03b398b234c2308 + source_hash: 3c79de31bf50147bdfa6cfc5ed55185e91bb55a8db986df0596b24d5529c7798 source_path: tools/plugin.md workflow: 15 --- @@ -50,8 +50,7 @@ openclaw plugins install @openclaw/voice-call - [Nostr](/channels/nostr) — `@openclaw/nostr` - [Zalo](/channels/zalo) — `@openclaw/zalo` - [Microsoft Teams](/channels/msteams) — `@openclaw/msteams` -- Google Antigravity OAuth(提供商认证)— 作为 `google-antigravity-auth` 捆绑(默认禁用) -- Gemini CLI OAuth(提供商认证)— 作为 `google-gemini-cli-auth` 捆绑(默认禁用) +- Google 网页搜索 + Gemini CLI OAuth — 作为 `google` 捆绑(网页搜索会自动加载;提供商认证仍需手动启用) - Qwen OAuth(提供商认证)— 作为 `qwen-portal-auth` 捆绑(默认禁用) - Copilot Proxy(提供商认证)— 本地 VS Code Copilot Proxy 桥接;与内置 `github-copilot` 设备登录不同(捆绑,默认禁用) From 39aba198f1530be2392e5e7e8a64606f31c95d83 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 02:08:09 +0000 Subject: [PATCH 116/943] fix(docs): run i18n through a local rpc client --- scripts/docs-i18n/pi_command.go | 120 +++++++++++ scripts/docs-i18n/pi_rpc_client.go | 302 +++++++++++++++++++++++++++ scripts/docs-i18n/translator.go | 25 +-- scripts/docs-i18n/translator_test.go | 62 +++++- 4 files changed, 483 insertions(+), 26 deletions(-) create mode 100644 scripts/docs-i18n/pi_command.go create mode 100644 scripts/docs-i18n/pi_rpc_client.go diff --git a/scripts/docs-i18n/pi_command.go b/scripts/docs-i18n/pi_command.go new file mode 100644 index 00000000000..c11c9134453 --- /dev/null +++ b/scripts/docs-i18n/pi_command.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + envDocsPiExecutable = "OPENCLAW_DOCS_I18N_PI_EXECUTABLE" + envDocsPiArgs = "OPENCLAW_DOCS_I18N_PI_ARGS" + envDocsPiPackageVersion = "OPENCLAW_DOCS_I18N_PI_PACKAGE_VERSION" + defaultPiPackageVersion = "0.58.3" +) + +type docsPiCommand struct { + Executable string + Args []string +} + +var ( + materializedPiRuntimeMu sync.Mutex + materializedPiRuntimeCommand docsPiCommand + materializedPiRuntimeErr error +) + +func resolveDocsPiCommand(ctx context.Context) (docsPiCommand, error) { + if executable := strings.TrimSpace(os.Getenv(envDocsPiExecutable)); executable != "" { + return docsPiCommand{ + Executable: executable, + Args: strings.Fields(os.Getenv(envDocsPiArgs)), + }, nil + } + + piPath, err := exec.LookPath("pi") + if err == nil && !shouldMaterializePiRuntime(piPath) { + return docsPiCommand{Executable: piPath}, nil + } + + return ensureMaterializedPiRuntime(ctx) +} + +func shouldMaterializePiRuntime(piPath string) bool { + realPath, err := filepath.EvalSymlinks(piPath) + if err != nil { + realPath = piPath + } + return strings.Contains(filepath.ToSlash(realPath), "/Projects/pi-mono/") +} + +func ensureMaterializedPiRuntime(ctx context.Context) (docsPiCommand, error) { + materializedPiRuntimeMu.Lock() + defer materializedPiRuntimeMu.Unlock() + + if materializedPiRuntimeErr == nil && materializedPiRuntimeCommand.Executable != "" { + return materializedPiRuntimeCommand, nil + } + + runtimeDir, err := getMaterializedPiRuntimeDir() + if err != nil { + materializedPiRuntimeErr = err + return docsPiCommand{}, err + } + cliPath := filepath.Join(runtimeDir, "node_modules", "@mariozechner", "pi-coding-agent", "dist", "cli.js") + if _, err := os.Stat(cliPath); errors.Is(err, os.ErrNotExist) { + installCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + if err := os.MkdirAll(runtimeDir, 0o755); err != nil { + materializedPiRuntimeErr = err + return docsPiCommand{}, err + } + + packageVersion := getMaterializedPiPackageVersion() + install := exec.CommandContext( + installCtx, + "npm", + "install", + "--silent", + "--no-audit", + "--no-fund", + fmt.Sprintf("@mariozechner/pi-coding-agent@%s", packageVersion), + ) + install.Dir = runtimeDir + install.Env = os.Environ() + output, err := install.CombinedOutput() + if err != nil { + materializedPiRuntimeErr = fmt.Errorf("materialize pi runtime: %w (%s)", err, strings.TrimSpace(string(output))) + return docsPiCommand{}, materializedPiRuntimeErr + } + } + + materializedPiRuntimeCommand = docsPiCommand{ + Executable: "node", + Args: []string{cliPath}, + } + materializedPiRuntimeErr = nil + return materializedPiRuntimeCommand, nil +} + +func getMaterializedPiRuntimeDir() (string, error) { + cacheDir, err := os.UserCacheDir() + if err != nil { + cacheDir = os.TempDir() + } + return filepath.Join(cacheDir, "openclaw", "docs-i18n", "pi-runtime", getMaterializedPiPackageVersion()), nil +} + +func getMaterializedPiPackageVersion() string { + if version := strings.TrimSpace(os.Getenv(envDocsPiPackageVersion)); version != "" { + return version + } + return defaultPiPackageVersion +} diff --git a/scripts/docs-i18n/pi_rpc_client.go b/scripts/docs-i18n/pi_rpc_client.go new file mode 100644 index 00000000000..d995c6a171f --- /dev/null +++ b/scripts/docs-i18n/pi_rpc_client.go @@ -0,0 +1,302 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" +) + +type docsPiClientOptions struct { + SystemPrompt string + Thinking string +} + +type docsPiClient struct { + process *exec.Cmd + stdin io.WriteCloser + stderr bytes.Buffer + events chan piEvent + promptLock sync.Mutex + closeOnce sync.Once + closed chan struct{} + requestID uint64 +} + +type piEvent struct { + Type string + Raw json.RawMessage +} + +type agentEndPayload struct { + Type string `json:"type,omitempty"` + Messages []agentMessage `json:"messages"` +} + +type rpcResponse struct { + ID string `json:"id,omitempty"` + Type string `json:"type"` + Command string `json:"command,omitempty"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +type agentMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + StopReason string `json:"stopReason,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` +} + +type contentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` +} + +func startDocsPiClient(ctx context.Context, options docsPiClientOptions) (*docsPiClient, error) { + command, err := resolveDocsPiCommand(ctx) + if err != nil { + return nil, err + } + + args := append([]string{}, command.Args...) + args = append(args, + "--mode", "rpc", + "--provider", "anthropic", + "--model", modelVersion, + "--thinking", options.Thinking, + "--no-session", + ) + if strings.TrimSpace(options.SystemPrompt) != "" { + args = append(args, "--system-prompt", options.SystemPrompt) + } + + process := exec.Command(command.Executable, args...) + agentDir, err := getDocsPiAgentDir() + if err != nil { + return nil, err + } + process.Env = append(os.Environ(), fmt.Sprintf("PI_CODING_AGENT_DIR=%s", agentDir)) + stdin, err := process.StdinPipe() + if err != nil { + return nil, err + } + stdout, err := process.StdoutPipe() + if err != nil { + return nil, err + } + stderr, err := process.StderrPipe() + if err != nil { + return nil, err + } + + client := &docsPiClient{ + process: process, + stdin: stdin, + events: make(chan piEvent, 256), + closed: make(chan struct{}), + } + + if err := process.Start(); err != nil { + return nil, err + } + + go client.captureStderr(stderr) + go client.readStdout(stdout) + + return client, nil +} + +func (client *docsPiClient) Prompt(ctx context.Context, message string) (string, error) { + client.promptLock.Lock() + defer client.promptLock.Unlock() + + command := map[string]string{ + "type": "prompt", + "id": fmt.Sprintf("req-%d", atomic.AddUint64(&client.requestID, 1)), + "message": message, + } + payload, err := json.Marshal(command) + if err != nil { + return "", err + } + + if _, err := client.stdin.Write(append(payload, '\n')); err != nil { + return "", err + } + + for { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-client.closed: + return "", errors.New("pi process closed") + case event, ok := <-client.events: + if !ok { + return "", errors.New("pi event stream closed") + } + if event.Type == "response" { + response, err := decodeRpcResponse(event.Raw) + if err != nil { + return "", err + } + if !response.Success { + if strings.TrimSpace(response.Error) == "" { + return "", errors.New("pi prompt failed") + } + return "", errors.New(strings.TrimSpace(response.Error)) + } + continue + } + if event.Type == "agent_end" { + return extractTranslationResult(event.Raw) + } + } + } +} + +func (client *docsPiClient) Stderr() string { + return client.stderr.String() +} + +func (client *docsPiClient) Close() error { + client.closeOnce.Do(func() { + close(client.closed) + if client.stdin != nil { + _ = client.stdin.Close() + } + if client.process != nil && client.process.Process != nil { + _ = client.process.Process.Signal(syscall.SIGTERM) + } + + done := make(chan struct{}) + go func() { + if client.process != nil { + _ = client.process.Wait() + } + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + if client.process != nil && client.process.Process != nil { + _ = client.process.Process.Kill() + } + } + }) + return nil +} + +func (client *docsPiClient) captureStderr(stderr io.Reader) { + _, _ = io.Copy(&client.stderr, stderr) +} + +func (client *docsPiClient) readStdout(stdout io.Reader) { + defer close(client.events) + + reader := bufio.NewReader(stdout) + for { + line, err := reader.ReadBytes('\n') + line = bytes.TrimSpace(line) + if len(line) > 0 { + var envelope struct { + Type string `json:"type"` + } + if json.Unmarshal(line, &envelope) == nil && envelope.Type != "" { + select { + case client.events <- piEvent{Type: envelope.Type, Raw: append([]byte{}, line...)}: + case <-client.closed: + return + } + } + } + if err != nil { + return + } + } +} + +func extractTranslationResult(raw json.RawMessage) (string, error) { + var payload agentEndPayload + if err := json.Unmarshal(raw, &payload); err != nil { + return "", err + } + for index := len(payload.Messages) - 1; index >= 0; index-- { + message := payload.Messages[index] + if message.Role != "assistant" { + continue + } + if message.ErrorMessage != "" || strings.EqualFold(message.StopReason, "error") { + msg := strings.TrimSpace(message.ErrorMessage) + if msg == "" { + msg = "unknown error" + } + return "", fmt.Errorf("pi error: %s", msg) + } + text, err := extractContentText(message.Content) + if err != nil { + return "", err + } + return text, nil + } + return "", errors.New("assistant message not found") +} + +func extractContentText(content json.RawMessage) (string, error) { + trimmed := strings.TrimSpace(string(content)) + if trimmed == "" { + return "", nil + } + if strings.HasPrefix(trimmed, "\"") { + var text string + if err := json.Unmarshal(content, &text); err != nil { + return "", err + } + return text, nil + } + + var blocks []contentBlock + if err := json.Unmarshal(content, &blocks); err != nil { + return "", err + } + + var parts []string + for _, block := range blocks { + if block.Type == "text" && block.Text != "" { + parts = append(parts, block.Text) + } + } + return strings.Join(parts, ""), nil +} + +func decodeRpcResponse(raw json.RawMessage) (rpcResponse, error) { + var response rpcResponse + if err := json.Unmarshal(raw, &response); err != nil { + return rpcResponse{}, err + } + return response, nil +} + +func getDocsPiAgentDir() (string, error) { + cacheDir, err := os.UserCacheDir() + if err != nil { + cacheDir = os.TempDir() + } + dir := filepath.Join(cacheDir, "openclaw", "docs-i18n", "agent") + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + return dir, nil +} diff --git a/scripts/docs-i18n/translator.go b/scripts/docs-i18n/translator.go index 8f7023c615b..122a30ec5d5 100644 --- a/scripts/docs-i18n/translator.go +++ b/scripts/docs-i18n/translator.go @@ -6,8 +6,6 @@ import ( "fmt" "strings" "time" - - pi "github.com/joshp123/pi-golang" ) const ( @@ -19,21 +17,14 @@ const ( var errEmptyTranslation = errors.New("empty translation") type PiTranslator struct { - client *pi.OneShotClient + client *docsPiClient } func NewPiTranslator(srcLang, tgtLang string, glossary []GlossaryEntry, thinking string) (*PiTranslator, error) { - options := pi.DefaultOneShotOptions() - options.AppName = "openclaw-docs-i18n" - options.WorkDir = "/tmp" - options.Mode = pi.ModeDragons - options.Dragons = pi.DragonsOptions{ - Provider: "anthropic", - Model: modelVersion, - Thinking: normalizeThinking(thinking), - } - options.SystemPrompt = translationPrompt(srcLang, tgtLang, glossary) - client, err := pi.StartOneShot(options) + client, err := startDocsPiClient(context.Background(), docsPiClientOptions{ + SystemPrompt: translationPrompt(srcLang, tgtLang, glossary), + Thinking: normalizeThinking(thinking), + }) if err != nil { return nil, err } @@ -146,7 +137,7 @@ func (t *PiTranslator) Close() { } type promptRunner interface { - Run(context.Context, string) (pi.RunResult, error) + Prompt(context.Context, string) (string, error) Stderr() string } @@ -154,11 +145,11 @@ func runPrompt(ctx context.Context, client promptRunner, message string) (string promptCtx, cancel := context.WithTimeout(ctx, translatePromptTimeout) defer cancel() - result, err := client.Run(promptCtx, message) + result, err := client.Prompt(promptCtx, message) if err != nil { return "", decoratePromptError(err, client.Stderr()) } - return result.Text, nil + return result, nil } func decoratePromptError(err error, stderr string) error { diff --git a/scripts/docs-i18n/translator_test.go b/scripts/docs-i18n/translator_test.go index a632e44e96e..3872d6dff07 100644 --- a/scripts/docs-i18n/translator_test.go +++ b/scripts/docs-i18n/translator_test.go @@ -3,20 +3,20 @@ package main import ( "context" "errors" + "os" + "path/filepath" "strings" "testing" "time" - - pi "github.com/joshp123/pi-golang" ) type fakePromptRunner struct { - run func(context.Context, string) (pi.RunResult, error) + prompt func(context.Context, string) (string, error) stderr string } -func (runner fakePromptRunner) Run(ctx context.Context, message string) (pi.RunResult, error) { - return runner.run(ctx, message) +func (runner fakePromptRunner) Prompt(ctx context.Context, message string) (string, error) { + return runner.prompt(ctx, message) } func (runner fakePromptRunner) Stderr() string { @@ -28,7 +28,7 @@ func TestRunPromptAddsTimeout(t *testing.T) { var deadline time.Time client := fakePromptRunner{ - run: func(ctx context.Context, message string) (pi.RunResult, error) { + prompt: func(ctx context.Context, message string) (string, error) { var ok bool deadline, ok = ctx.Deadline() if !ok { @@ -37,7 +37,7 @@ func TestRunPromptAddsTimeout(t *testing.T) { if message != "Translate me" { t.Fatalf("unexpected message %q", message) } - return pi.RunResult{Text: "translated"}, nil + return "translated", nil }, } @@ -60,8 +60,8 @@ func TestRunPromptIncludesStderr(t *testing.T) { rootErr := errors.New("context deadline exceeded") client := fakePromptRunner{ - run: func(context.Context, string) (pi.RunResult, error) { - return pi.RunResult{}, rootErr + prompt: func(context.Context, string) (string, error) { + return "", rootErr }, stderr: "boom", } @@ -90,3 +90,47 @@ func TestDecoratePromptErrorLeavesCleanErrorsAlone(t *testing.T) { t.Fatalf("expected unchanged message, got %v", got) } } + +func TestResolveDocsPiCommandUsesOverrideEnv(t *testing.T) { + t.Setenv(envDocsPiExecutable, "/tmp/custom-pi") + t.Setenv(envDocsPiArgs, "--mode rpc --foo bar") + + command, err := resolveDocsPiCommand(context.Background()) + if err != nil { + t.Fatalf("resolveDocsPiCommand returned error: %v", err) + } + + if command.Executable != "/tmp/custom-pi" { + t.Fatalf("unexpected executable %q", command.Executable) + } + if strings.Join(command.Args, " ") != "--mode rpc --foo bar" { + t.Fatalf("unexpected args %v", command.Args) + } +} + +func TestShouldMaterializePiRuntimeForPiMonoWrapper(t *testing.T) { + t.Parallel() + + root := t.TempDir() + sourceDir := filepath.Join(root, "Projects", "pi-mono", "packages", "coding-agent", "dist") + binDir := filepath.Join(root, "bin") + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + t.Fatalf("mkdir source dir: %v", err) + } + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin dir: %v", err) + } + + target := filepath.Join(sourceDir, "cli.js") + if err := os.WriteFile(target, []byte("console.log('pi');\n"), 0o644); err != nil { + t.Fatalf("write target: %v", err) + } + link := filepath.Join(binDir, "pi") + if err := os.Symlink(target, link); err != nil { + t.Fatalf("symlink: %v", err) + } + + if !shouldMaterializePiRuntime(link) { + t.Fatal("expected pi-mono wrapper to materialize runtime") + } +} From 2b57d3bb34bb0dca2396b8b612af59c60b1ad9d4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 02:08:15 +0000 Subject: [PATCH 117/943] build(plugin-sdk): enforce export sync in check --- package.json | 3 ++- scripts/sync-plugin-sdk-exports.mjs | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index cc6925725fa..49fa28e6b2d 100644 --- a/package.json +++ b/package.json @@ -226,7 +226,7 @@ "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json || true", "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", + "check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links", "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", @@ -292,6 +292,7 @@ "moltbot:rpc": "node scripts/run-node.mjs agent --mode rpc --json", "openclaw": "node scripts/run-node.mjs", "openclaw:rpc": "node scripts/run-node.mjs agent --mode rpc --json", + "plugin-sdk:check-exports": "node scripts/sync-plugin-sdk-exports.mjs --check", "plugin-sdk:sync-exports": "node scripts/sync-plugin-sdk-exports.mjs", "plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts", "prepack": "pnpm build && pnpm ui:build", diff --git a/scripts/sync-plugin-sdk-exports.mjs b/scripts/sync-plugin-sdk-exports.mjs index cfe2e181259..b7e0aa29ae5 100644 --- a/scripts/sync-plugin-sdk-exports.mjs +++ b/scripts/sync-plugin-sdk-exports.mjs @@ -4,6 +4,7 @@ import fs from "node:fs"; import path from "node:path"; import { buildPluginSdkPackageExports } from "./lib/plugin-sdk-entries.mjs"; +const checkOnly = process.argv.includes("--check"); const packageJsonPath = path.join(process.cwd(), "package.json"); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); const currentExports = packageJson.exports ?? {}; @@ -30,5 +31,16 @@ if (!insertedPluginSdkExports) { Object.assign(nextExports, syncedPluginSdkExports); } +const nextExportsJson = JSON.stringify(nextExports); +const currentExportsJson = JSON.stringify(currentExports); +if (checkOnly) { + if (currentExportsJson !== nextExportsJson) { + console.error("plugin-sdk exports out of sync. Run `pnpm plugin-sdk:sync-exports`."); + process.exit(1); + } + console.log("plugin-sdk exports synced."); + process.exit(0); +} + packageJson.exports = nextExports; fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8"); From 10f4a03de88799316c38e05c9fc1bb364a24d281 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 02:08:32 +0000 Subject: [PATCH 118/943] docs(google): remove stale plugin references --- .github/labeler.yml | 8 -------- docs/cli/index.md | 2 +- docs/zh-CN/cli/index.md | 2 +- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index d980a8d096e..b6422060fea 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -198,14 +198,6 @@ - changed-files: - any-glob-to-any-file: - "extensions/diagnostics-otel/**" -"extensions: google-antigravity-auth": - - changed-files: - - any-glob-to-any-file: - - "extensions/google-antigravity-auth/**" -"extensions: google-gemini-cli-auth": - - changed-files: - - any-glob-to-any-file: - - "extensions/google-gemini-cli-auth/**" "extensions: llm-task": - changed-files: - any-glob-to-any-file: diff --git a/docs/cli/index.md b/docs/cli/index.md index ddedc7ca1aa..fbc0bf1378f 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -676,7 +676,7 @@ Surfaces: Notes: - Data comes directly from provider usage endpoints (no estimates). -- Providers: Anthropic, GitHub Copilot, OpenAI Codex OAuth, plus Gemini CLI/Antigravity when those provider plugins are enabled. +- Providers: Anthropic, GitHub Copilot, OpenAI Codex OAuth, plus Gemini CLI via the bundled `google` plugin and Antigravity where configured. - If no matching credentials exist, usage is hidden. - Details: see [Usage tracking](/concepts/usage-tracking). diff --git a/docs/zh-CN/cli/index.md b/docs/zh-CN/cli/index.md index c22fad5c4b4..e7ae99ef935 100644 --- a/docs/zh-CN/cli/index.md +++ b/docs/zh-CN/cli/index.md @@ -589,7 +589,7 @@ Gmail Pub/Sub 钩子设置 + 运行器。参见 [/automation/gmail-pubsub](/auto 说明: - 数据直接来自提供商用量端点(非估算)。 -- 提供商:Anthropic、GitHub Copilot、OpenAI Codex OAuth,以及启用这些提供商插件时的 Gemini CLI/Antigravity。 +- 提供商:Anthropic、GitHub Copilot、OpenAI Codex OAuth,以及通过捆绑 `google` 插件提供的 Gemini CLI 和已配置的 Antigravity。 - 如果没有匹配的凭证,用量会被隐藏。 - 详情:参见[用量跟踪](/concepts/usage-tracking)。 From 9785b44307fa3f96ccae15e5726fd38a592af1e4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:12:38 -0700 Subject: [PATCH 119/943] IRC: split setup adapter helpers --- extensions/irc/src/channel.ts | 3 +- extensions/irc/src/setup-core.ts | 147 +++++++++++++++++++++++++ extensions/irc/src/setup-surface.ts | 162 ++++------------------------ 3 files changed, 169 insertions(+), 143 deletions(-) create mode 100644 extensions/irc/src/setup-core.ts diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index b1fd0fc89d8..ca53d53a93d 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -36,7 +36,8 @@ import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js"; import { probeIrc } from "./probe.js"; import { getIrcRuntime } from "./runtime.js"; import { sendMessageIrc } from "./send.js"; -import { ircSetupAdapter, ircSetupWizard } from "./setup-surface.js"; +import { ircSetupAdapter } from "./setup-core.js"; +import { ircSetupWizard } from "./setup-surface.js"; import type { CoreConfig, IrcProbe } from "./types.js"; const meta = getChatChannelMeta("irc"); diff --git a/extensions/irc/src/setup-core.ts b/extensions/irc/src/setup-core.ts new file mode 100644 index 00000000000..45f9041f973 --- /dev/null +++ b/extensions/irc/src/setup-core.ts @@ -0,0 +1,147 @@ +import { + setTopLevelChannelAllowFrom, + setTopLevelChannelDmPolicyWithAllowFrom, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + patchScopedAccountConfig, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; +import type { DmPolicy } from "../../../src/config/types.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; +import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js"; + +const channel = "irc" as const; + +type IrcSetupInput = ChannelSetupInput & { + host?: string; + port?: number | string; + tls?: boolean; + nick?: string; + username?: string; + realname?: string; + channels?: string[]; + password?: string; +}; + +export function parsePort(raw: string, fallback: number): number { + const trimmed = raw.trim(); + if (!trimmed) { + return fallback; + } + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) { + return fallback; + } + return parsed; +} + +export function updateIrcAccountConfig( + cfg: CoreConfig, + accountId: string, + patch: Partial, +): CoreConfig { + return patchScopedAccountConfig({ + cfg, + channelKey: channel, + accountId, + patch, + ensureChannelEnabled: false, + ensureAccountEnabled: false, + }) as CoreConfig; +} + +export function setIrcDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { + return setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy, + }) as CoreConfig; +} + +export function setIrcAllowFrom(cfg: CoreConfig, allowFrom: string[]): CoreConfig { + return setTopLevelChannelAllowFrom({ + cfg, + channel, + allowFrom, + }) as CoreConfig; +} + +export function setIrcNickServ( + cfg: CoreConfig, + accountId: string, + nickserv?: IrcNickServConfig, +): CoreConfig { + return updateIrcAccountConfig(cfg, accountId, { nickserv }); +} + +export function setIrcGroupAccess( + cfg: CoreConfig, + accountId: string, + policy: "open" | "allowlist" | "disabled", + entries: string[], + normalizeGroupEntry: (raw: string) => string | null, +): CoreConfig { + if (policy !== "allowlist") { + return updateIrcAccountConfig(cfg, accountId, { enabled: true, groupPolicy: policy }); + } + const normalizedEntries = [ + ...new Set(entries.map((entry) => normalizeGroupEntry(entry)).filter(Boolean)), + ]; + const groups = Object.fromEntries(normalizedEntries.map((entry) => [entry, {}])); + return updateIrcAccountConfig(cfg, accountId, { + enabled: true, + groupPolicy: "allowlist", + groups, + }); +} + +export const ircSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ input }) => { + const setupInput = input as IrcSetupInput; + if (!setupInput.host?.trim()) { + return "IRC requires host."; + } + if (!setupInput.nick?.trim()) { + return "IRC requires nick."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const setupInput = input as IrcSetupInput; + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: setupInput.name, + }); + const portInput = + typeof setupInput.port === "number" ? String(setupInput.port) : String(setupInput.port ?? ""); + const patch: Partial = { + enabled: true, + host: setupInput.host?.trim(), + port: portInput ? parsePort(portInput, setupInput.tls === false ? 6667 : 6697) : undefined, + tls: setupInput.tls, + nick: setupInput.nick?.trim(), + username: setupInput.username?.trim(), + realname: setupInput.realname?.trim(), + password: setupInput.password?.trim(), + channels: setupInput.channels, + }; + return patchScopedAccountConfig({ + cfg: namedConfig, + channelKey: channel, + accountId, + patch, + }) as CoreConfig; + }, +}; diff --git a/extensions/irc/src/setup-surface.ts b/extensions/irc/src/setup-surface.ts index aaee61a9532..63a7bec920b 100644 --- a/extensions/irc/src/setup-surface.ts +++ b/extensions/irc/src/setup-surface.ts @@ -2,18 +2,10 @@ import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/on import { resolveOnboardingAccountId, setOnboardingChannelEnabled, - setTopLevelChannelAllowFrom, - setTopLevelChannelDmPolicyWithAllowFrom, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - patchScopedAccountConfig, -} from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; import type { DmPolicy } from "../../../src/config/types.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { listIrcAccountIds, resolveDefaultIrcAccountId, resolveIrcAccount } from "./accounts.js"; @@ -22,23 +14,21 @@ import { normalizeIrcAllowEntry, normalizeIrcMessagingTarget, } from "./normalize.js"; +import { + ircSetupAdapter, + parsePort, + setIrcAllowFrom, + setIrcDmPolicy, + setIrcGroupAccess, + setIrcNickServ, + updateIrcAccountConfig, +} from "./setup-core.js"; import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js"; const channel = "irc" as const; const USE_ENV_FLAG = "__ircUseEnv"; const TLS_FLAG = "__ircTls"; -type IrcSetupInput = ChannelSetupInput & { - host?: string; - port?: number | string; - tls?: boolean; - nick?: string; - username?: string; - realname?: string; - channels?: string[]; - password?: string; -}; - function parseListInput(raw: string): string[] { return raw .split(/[\n,;]+/g) @@ -46,18 +36,6 @@ function parseListInput(raw: string): string[] { .filter(Boolean); } -function parsePort(raw: string, fallback: number): number { - const trimmed = raw.trim(); - if (!trimmed) { - return fallback; - } - const parsed = Number.parseInt(trimmed, 10); - if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) { - return fallback; - } - return parsed; -} - function normalizeGroupEntry(raw: string): string | null { const trimmed = raw.trim(); if (!trimmed) { @@ -73,65 +51,6 @@ function normalizeGroupEntry(raw: string): string | null { return `#${normalized.replace(/^#+/, "")}`; } -function updateIrcAccountConfig( - cfg: CoreConfig, - accountId: string, - patch: Partial, -): CoreConfig { - return patchScopedAccountConfig({ - cfg, - channelKey: channel, - accountId, - patch, - ensureChannelEnabled: false, - ensureAccountEnabled: false, - }) as CoreConfig; -} - -function setIrcDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }) as CoreConfig; -} - -function setIrcAllowFrom(cfg: CoreConfig, allowFrom: string[]): CoreConfig { - return setTopLevelChannelAllowFrom({ - cfg, - channel, - allowFrom, - }) as CoreConfig; -} - -function setIrcNickServ( - cfg: CoreConfig, - accountId: string, - nickserv?: IrcNickServConfig, -): CoreConfig { - return updateIrcAccountConfig(cfg, accountId, { nickserv }); -} - -function setIrcGroupAccess( - cfg: CoreConfig, - accountId: string, - policy: "open" | "allowlist" | "disabled", - entries: string[], -): CoreConfig { - if (policy !== "allowlist") { - return updateIrcAccountConfig(cfg, accountId, { enabled: true, groupPolicy: policy }); - } - const normalizedEntries = [ - ...new Set(entries.map((entry) => normalizeGroupEntry(entry)).filter(Boolean)), - ]; - const groups = Object.fromEntries(normalizedEntries.map((entry) => [entry, {}])); - return updateIrcAccountConfig(cfg, accountId, { - enabled: true, - groupPolicy: "allowlist", - groups, - }); -} - async function promptIrcAllowFrom(params: { cfg: CoreConfig; prompter: WizardPrompter; @@ -264,55 +183,6 @@ const ircDmPolicy: ChannelOnboardingDmPolicy = { }), }; -export const ircSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ input }) => { - const setupInput = input as IrcSetupInput; - if (!setupInput.host?.trim()) { - return "IRC requires host."; - } - if (!setupInput.nick?.trim()) { - return "IRC requires nick."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const setupInput = input as IrcSetupInput; - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: setupInput.name, - }); - const portInput = - typeof setupInput.port === "number" ? String(setupInput.port) : String(setupInput.port ?? ""); - const patch: Partial = { - enabled: true, - host: setupInput.host?.trim(), - port: portInput ? parsePort(portInput, setupInput.tls === false ? 6667 : 6697) : undefined, - tls: setupInput.tls, - nick: setupInput.nick?.trim(), - username: setupInput.username?.trim(), - realname: setupInput.realname?.trim(), - password: setupInput.password?.trim(), - channels: setupInput.channels, - }; - return patchScopedAccountConfig({ - cfg: namedConfig, - channelKey: channel, - accountId, - patch, - }) as CoreConfig; - }, -}; - export const ircSetupWizard: ChannelSetupWizard = { channel, status: { @@ -509,11 +379,17 @@ export const ircSetupWizard: ChannelSetupWizard = { updatePrompt: ({ cfg, accountId }) => Boolean(resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.groups), setPolicy: ({ cfg, accountId, policy }) => - setIrcGroupAccess(cfg as CoreConfig, accountId, policy, []), + setIrcGroupAccess(cfg as CoreConfig, accountId, policy, [], normalizeGroupEntry), resolveAllowlist: async ({ entries }) => [...new Set(entries.map((entry) => normalizeGroupEntry(entry)).filter(Boolean))] as string[], applyAllowlist: ({ cfg, accountId, resolved }) => - setIrcGroupAccess(cfg as CoreConfig, accountId, "allowlist", resolved as string[]), + setIrcGroupAccess( + cfg as CoreConfig, + accountId, + "allowlist", + resolved as string[], + normalizeGroupEntry, + ), }, allowFrom: { helpTitle: "IRC allowlist", @@ -584,3 +460,5 @@ export const ircSetupWizard: ChannelSetupWizard = { dmPolicy: ircDmPolicy, disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), }; + +export { ircSetupAdapter }; From ec93398d7be6c2e7124d6eb9234a972c5395e310 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 19:14:11 -0700 Subject: [PATCH 120/943] refactor: move line to setup wizard --- extensions/line/src/channel.ts | 134 +-------- extensions/line/src/setup-surface.test.ts | 77 +++++ extensions/line/src/setup-surface.ts | 350 ++++++++++++++++++++++ src/plugin-sdk/line.ts | 2 + src/plugin-sdk/subpaths.test.ts | 2 + 5 files changed, 434 insertions(+), 131 deletions(-) create mode 100644 extensions/line/src/setup-surface.test.ts create mode 100644 extensions/line/src/setup-surface.ts diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index 982d7670082..4c2b51cd6d0 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -20,6 +20,7 @@ import { type ResolvedLineAccount, } from "openclaw/plugin-sdk/line"; import { getLineRuntime } from "./runtime.js"; +import { lineSetupAdapter, lineSetupWizard } from "./setup-surface.js"; // LINE channel metadata const meta = { @@ -62,42 +63,6 @@ const resolveLineDmPolicy = createScopedDmSecurityResolver( normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""), }); -function patchLineAccountConfig( - cfg: OpenClawConfig, - lineConfig: LineConfig, - accountId: string, - patch: Record, -): OpenClawConfig { - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - line: { - ...lineConfig, - ...patch, - }, - }, - }; - } - return { - ...cfg, - channels: { - ...cfg.channels, - line: { - ...lineConfig, - accounts: { - ...lineConfig.accounts, - [accountId]: { - ...lineConfig.accounts?.[accountId], - ...patch, - }, - }, - }, - }, - }; -} - export const linePlugin: ChannelPlugin = { id: "line", meta: { @@ -131,6 +96,7 @@ export const linePlugin: ChannelPlugin = { }, reload: { configPrefixes: ["channels.line"] }, configSchema: buildChannelConfigSchema(LineConfigSchema), + setupWizard: lineSetupWizard, config: { ...lineConfigBase, isConfigured: (account) => @@ -200,101 +166,7 @@ export const linePlugin: ChannelPlugin = { listPeers: async () => [], listGroups: async () => [], }, - setup: { - resolveAccountId: ({ accountId }) => - getLineRuntime().channel.line.normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => { - const lineConfig = (cfg.channels?.line ?? {}) as LineConfig; - return patchLineAccountConfig(cfg, lineConfig, accountId, { name }); - }, - validateInput: ({ accountId, input }) => { - const typedInput = input as { - useEnv?: boolean; - channelAccessToken?: string; - channelSecret?: string; - tokenFile?: string; - secretFile?: string; - }; - if (typedInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account."; - } - if (!typedInput.useEnv && !typedInput.channelAccessToken && !typedInput.tokenFile) { - return "LINE requires channelAccessToken or --token-file (or --use-env)."; - } - if (!typedInput.useEnv && !typedInput.channelSecret && !typedInput.secretFile) { - return "LINE requires channelSecret or --secret-file (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const typedInput = input as { - name?: string; - useEnv?: boolean; - channelAccessToken?: string; - channelSecret?: string; - tokenFile?: string; - secretFile?: string; - }; - const lineConfig = (cfg.channels?.line ?? {}) as LineConfig; - - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - line: { - ...lineConfig, - enabled: true, - ...(typedInput.name ? { name: typedInput.name } : {}), - ...(typedInput.useEnv - ? {} - : typedInput.tokenFile - ? { tokenFile: typedInput.tokenFile } - : typedInput.channelAccessToken - ? { channelAccessToken: typedInput.channelAccessToken } - : {}), - ...(typedInput.useEnv - ? {} - : typedInput.secretFile - ? { secretFile: typedInput.secretFile } - : typedInput.channelSecret - ? { channelSecret: typedInput.channelSecret } - : {}), - }, - }, - }; - } - - return { - ...cfg, - channels: { - ...cfg.channels, - line: { - ...lineConfig, - enabled: true, - accounts: { - ...lineConfig.accounts, - [accountId]: { - ...lineConfig.accounts?.[accountId], - enabled: true, - ...(typedInput.name ? { name: typedInput.name } : {}), - ...(typedInput.tokenFile - ? { tokenFile: typedInput.tokenFile } - : typedInput.channelAccessToken - ? { channelAccessToken: typedInput.channelAccessToken } - : {}), - ...(typedInput.secretFile - ? { secretFile: typedInput.secretFile } - : typedInput.channelSecret - ? { channelSecret: typedInput.channelSecret } - : {}), - }, - }, - }, - }, - }; - }, - }, + setup: lineSetupAdapter, outbound: { deliveryMode: "direct", chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit), diff --git a/extensions/line/src/setup-surface.test.ts b/extensions/line/src/setup-surface.test.ts new file mode 100644 index 00000000000..9fbddc19675 --- /dev/null +++ b/extensions/line/src/setup-surface.test.ts @@ -0,0 +1,77 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/line"; +import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { + listLineAccountIds, + resolveDefaultLineAccountId, + resolveLineAccount, +} from "../../../src/line/accounts.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { lineSetupAdapter, lineSetupWizard } from "./setup-surface.js"; + +function createPrompter(overrides: Partial = {}): WizardPrompter { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: vi.fn(async ({ options }: { options: Array<{ value: string }> }) => { + const first = options[0]; + if (!first) { + throw new Error("no options"); + } + return first.value; + }) as WizardPrompter["select"], + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} + +const lineConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: { + id: "line", + meta: { label: "LINE" }, + config: { + listAccountIds: listLineAccountIds, + defaultAccountId: resolveDefaultLineAccountId, + resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => + resolveLineAccount({ cfg, accountId: accountId ?? undefined }).config.allowFrom, + }, + setup: lineSetupAdapter, + } as Parameters[0]["plugin"], + wizard: lineSetupWizard, +}); + +describe("line setup wizard", () => { + it("configures token and secret for the default account", async () => { + const prompter = createPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Enter LINE channel access token") { + return "line-token"; + } + if (message === "Enter LINE channel secret") { + return "line-secret"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + }); + + const result = await lineConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime: createRuntimeEnv(), + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.line?.enabled).toBe(true); + expect(result.cfg.channels?.line?.channelAccessToken).toBe("line-token"); + expect(result.cfg.channels?.line?.channelSecret).toBe("line-secret"); + }); +}); diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts new file mode 100644 index 00000000000..1b7a22dfb11 --- /dev/null +++ b/extensions/line/src/setup-surface.ts @@ -0,0 +1,350 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + setOnboardingChannelEnabled, + setTopLevelChannelDmPolicyWithAllowFrom, + splitOnboardingEntries, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { + listLineAccountIds, + normalizeAccountId, + resolveLineAccount, +} from "../../../src/line/accounts.js"; +import type { LineConfig } from "../../../src/line/types.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; + +const channel = "line" as const; + +const LINE_SETUP_HELP_LINES = [ + "1) Open the LINE Developers Console and create or pick a Messaging API channel", + "2) Copy the channel access token and channel secret", + "3) Enable Use webhook in the Messaging API settings", + "4) Point the webhook at https:///line/webhook", + `Docs: ${formatDocsLink("/channels/line", "channels/line")}`, +]; + +const LINE_ALLOW_FROM_HELP_LINES = [ + "Allowlist LINE DMs by user id.", + "LINE ids are case-sensitive.", + "Examples:", + "- U1234567890abcdef1234567890abcdef", + "- line:user:U1234567890abcdef1234567890abcdef", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/channels/line", "channels/line")}`, +]; + +function patchLineAccountConfig(params: { + cfg: OpenClawConfig; + accountId: string; + patch: Record; + clearFields?: string[]; + enabled?: boolean; +}): OpenClawConfig { + const accountId = normalizeAccountId(params.accountId); + const lineConfig = ((params.cfg.channels?.line ?? {}) as LineConfig) ?? {}; + const clearFields = params.clearFields ?? []; + + if (accountId === DEFAULT_ACCOUNT_ID) { + const nextLine = { ...lineConfig } as Record; + for (const field of clearFields) { + delete nextLine[field]; + } + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + line: { + ...nextLine, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }, + }, + }; + } + + const nextAccount = { + ...(lineConfig.accounts?.[accountId] ?? {}), + } as Record; + for (const field of clearFields) { + delete nextAccount[field]; + } + + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + line: { + ...lineConfig, + ...(params.enabled ? { enabled: true } : {}), + accounts: { + ...lineConfig.accounts, + [accountId]: { + ...nextAccount, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }, + }, + }, + }, + }; +} + +function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean { + const resolved = resolveLineAccount({ cfg, accountId }); + return Boolean(resolved.channelAccessToken.trim() && resolved.channelSecret.trim()); +} + +function parseLineAllowFromId(raw: string): string | null { + const trimmed = raw.trim().replace(/^line:(?:user:)?/i, ""); + if (!/^U[a-f0-9]{32}$/i.test(trimmed)) { + return null; + } + return trimmed; +} + +const lineDmPolicy: ChannelOnboardingDmPolicy = { + label: "LINE", + channel, + policyKey: "channels.line.dmPolicy", + allowFromKey: "channels.line.allowFrom", + getCurrent: (cfg) => cfg.channels?.line?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => + setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), +}; + +export const lineSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + patchLineAccountConfig({ + cfg, + accountId, + patch: name?.trim() ? { name: name.trim() } : {}, + }), + validateInput: ({ accountId, input }) => { + const typedInput = input as { + useEnv?: boolean; + channelAccessToken?: string; + channelSecret?: string; + tokenFile?: string; + secretFile?: string; + }; + if (typedInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account."; + } + if (!typedInput.useEnv && !typedInput.channelAccessToken && !typedInput.tokenFile) { + return "LINE requires channelAccessToken or --token-file (or --use-env)."; + } + if (!typedInput.useEnv && !typedInput.channelSecret && !typedInput.secretFile) { + return "LINE requires channelSecret or --secret-file (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const typedInput = input as { + useEnv?: boolean; + channelAccessToken?: string; + channelSecret?: string; + tokenFile?: string; + secretFile?: string; + }; + const normalizedAccountId = normalizeAccountId(accountId); + if (normalizedAccountId === DEFAULT_ACCOUNT_ID) { + return patchLineAccountConfig({ + cfg, + accountId: normalizedAccountId, + enabled: true, + clearFields: typedInput.useEnv + ? ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"] + : undefined, + patch: typedInput.useEnv + ? {} + : { + ...(typedInput.tokenFile + ? { tokenFile: typedInput.tokenFile } + : typedInput.channelAccessToken + ? { channelAccessToken: typedInput.channelAccessToken } + : {}), + ...(typedInput.secretFile + ? { secretFile: typedInput.secretFile } + : typedInput.channelSecret + ? { channelSecret: typedInput.channelSecret } + : {}), + }, + }); + } + return patchLineAccountConfig({ + cfg, + accountId: normalizedAccountId, + enabled: true, + patch: { + ...(typedInput.tokenFile + ? { tokenFile: typedInput.tokenFile } + : typedInput.channelAccessToken + ? { channelAccessToken: typedInput.channelAccessToken } + : {}), + ...(typedInput.secretFile + ? { secretFile: typedInput.secretFile } + : typedInput.channelSecret + ? { channelSecret: typedInput.channelSecret } + : {}), + }, + }); + }, +}; + +export const lineSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs token + secret", + configuredHint: "configured", + unconfiguredHint: "needs token + secret", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => + listLineAccountIds(cfg).some((accountId) => isLineConfigured(cfg, accountId)), + resolveStatusLines: ({ cfg, configured }) => [ + `LINE: ${configured ? "configured" : "needs token + secret"}`, + `Accounts: ${listLineAccountIds(cfg).length || 0}`, + ], + }, + introNote: { + title: "LINE Messaging API", + lines: LINE_SETUP_HELP_LINES, + shouldShow: ({ cfg, accountId }) => !isLineConfigured(cfg, accountId), + }, + credentials: [ + { + inputKey: "token", + providerHint: channel, + credentialLabel: "channel access token", + preferredEnvVar: "LINE_CHANNEL_ACCESS_TOKEN", + helpTitle: "LINE Messaging API", + helpLines: LINE_SETUP_HELP_LINES, + envPrompt: "LINE_CHANNEL_ACCESS_TOKEN detected. Use env var?", + keepPrompt: "LINE channel access token already configured. Keep it?", + inputPrompt: "Enter LINE channel access token", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const resolved = resolveLineAccount({ cfg, accountId }); + return { + accountConfigured: Boolean( + resolved.channelAccessToken.trim() && resolved.channelSecret.trim(), + ), + hasConfiguredValue: Boolean( + resolved.config.channelAccessToken?.trim() || resolved.config.tokenFile?.trim(), + ), + resolvedValue: resolved.channelAccessToken.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() || undefined + : undefined, + }; + }, + applyUseEnv: ({ cfg, accountId }) => + patchLineAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: ["channelAccessToken", "tokenFile"], + patch: {}, + }), + applySet: ({ cfg, accountId, resolvedValue }) => + patchLineAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: ["tokenFile"], + patch: { channelAccessToken: resolvedValue }, + }), + }, + { + inputKey: "password", + providerHint: "line-secret", + credentialLabel: "channel secret", + preferredEnvVar: "LINE_CHANNEL_SECRET", + helpTitle: "LINE Messaging API", + helpLines: LINE_SETUP_HELP_LINES, + envPrompt: "LINE_CHANNEL_SECRET detected. Use env var?", + keepPrompt: "LINE channel secret already configured. Keep it?", + inputPrompt: "Enter LINE channel secret", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const resolved = resolveLineAccount({ cfg, accountId }); + return { + accountConfigured: Boolean( + resolved.channelAccessToken.trim() && resolved.channelSecret.trim(), + ), + hasConfiguredValue: Boolean( + resolved.config.channelSecret?.trim() || resolved.config.secretFile?.trim(), + ), + resolvedValue: resolved.channelSecret.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.LINE_CHANNEL_SECRET?.trim() || undefined + : undefined, + }; + }, + applyUseEnv: ({ cfg, accountId }) => + patchLineAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: ["channelSecret", "secretFile"], + patch: {}, + }), + applySet: ({ cfg, accountId, resolvedValue }) => + patchLineAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: ["secretFile"], + patch: { channelSecret: resolvedValue }, + }), + }, + ], + allowFrom: { + helpTitle: "LINE allowlist", + helpLines: LINE_ALLOW_FROM_HELP_LINES, + message: "LINE allowFrom (user id)", + placeholder: "U1234567890abcdef1234567890abcdef", + invalidWithoutCredentialNote: + "LINE allowFrom requires raw user ids like U1234567890abcdef1234567890abcdef.", + parseInputs: splitOnboardingEntries, + parseId: parseLineAllowFromId, + resolveEntries: async ({ entries }) => + entries.map((entry) => { + const id = parseLineAllowFromId(entry); + return { + input: entry, + resolved: Boolean(id), + id, + }; + }), + apply: ({ cfg, accountId, allowFrom }) => + patchLineAccountConfig({ + cfg, + accountId, + enabled: true, + patch: { dmPolicy: "allowlist", allowFrom }, + }), + }, + dmPolicy: lineDmPolicy, + completionNote: { + title: "LINE webhook", + lines: [ + "Enable Use webhook in the LINE console after saving credentials.", + "Default webhook URL: https:///line/webhook", + "If you set channels.line.webhookPath, update the URL to match.", + `Docs: ${formatDocsLink("/channels/line", "channels/line")}`, + ], + }, + disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), +}; diff --git a/src/plugin-sdk/line.ts b/src/plugin-sdk/line.ts index 0318e5ac1e7..d0c6ffcaf86 100644 --- a/src/plugin-sdk/line.ts +++ b/src/plugin-sdk/line.ts @@ -8,6 +8,7 @@ export type { OpenClawConfig } from "../config/config.js"; export type { ReplyPayload } from "../auto-reply/types.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; +export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; @@ -26,6 +27,7 @@ export { buildTokenChannelStatusSummary, } from "./status-helpers.js"; +export { lineSetupAdapter, lineSetupWizard } from "../../extensions/line/src/setup-surface.js"; export { LineConfigSchema } from "../line/config-schema.js"; export type { LineChannelData, LineConfig, ResolvedLineAccount } from "../line/types.js"; export { diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 6b696be7269..3315cbe5963 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -82,6 +82,8 @@ describe("plugin-sdk subpath exports", () => { it("exports LINE helpers", () => { expect(typeof lineSdk.processLineMessage).toBe("function"); expect(typeof lineSdk.createInfoCard).toBe("function"); + expect(typeof lineSdk.lineSetupWizard).toBe("object"); + expect(typeof lineSdk.lineSetupAdapter).toBe("object"); }); it("exports Microsoft Teams helpers", () => { From 60bf58ddbc7cc174f80c1000d683620962a95789 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 19:14:21 -0700 Subject: [PATCH 121/943] refactor: trim onboarding sdk exports --- docs/refactor/plugin-sdk.md | 2 +- extensions/mattermost/src/onboarding-helpers.ts | 1 - src/plugin-sdk/bluebubbles.ts | 2 -- src/plugin-sdk/index.ts | 16 +--------------- src/plugin-sdk/mattermost.ts | 3 --- src/plugin-sdk/nextcloud-talk.ts | 2 -- 6 files changed, 2 insertions(+), 24 deletions(-) delete mode 100644 extensions/mattermost/src/onboarding-helpers.ts diff --git a/docs/refactor/plugin-sdk.md b/docs/refactor/plugin-sdk.md index 4722644083b..a6a10cf9472 100644 --- a/docs/refactor/plugin-sdk.md +++ b/docs/refactor/plugin-sdk.md @@ -28,7 +28,7 @@ Contents (examples): - Config helpers: `buildChannelConfigSchema`, `setAccountEnabledInConfigSection`, `deleteAccountFromConfigSection`, `applyAccountNameToChannelSection`. - Pairing helpers: `PAIRING_APPROVED_MESSAGE`, `formatPairingApproveHint`. -- Onboarding helpers: `promptChannelAccessConfig`, `addWildcardAllowFrom`, onboarding types. +- Setup entry points: host-owned `setup` + `setupWizard`; avoid broad public onboarding helpers. - Tool param helpers: `createActionGate`, `readStringParam`, `readNumberParam`, `readReactionParams`, `jsonResult`. - Docs link helper: `formatDocsLink`. diff --git a/extensions/mattermost/src/onboarding-helpers.ts b/extensions/mattermost/src/onboarding-helpers.ts deleted file mode 100644 index e78abf5ebec..00000000000 --- a/extensions/mattermost/src/onboarding-helpers.ts +++ /dev/null @@ -1 +0,0 @@ -export { promptAccountId, resolveAccountIdForConfigure } from "openclaw/plugin-sdk/mattermost"; diff --git a/src/plugin-sdk/bluebubbles.ts b/src/plugin-sdk/bluebubbles.ts index dff21c19bd7..4527f24917d 100644 --- a/src/plugin-sdk/bluebubbles.ts +++ b/src/plugin-sdk/bluebubbles.ts @@ -34,8 +34,6 @@ export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js export { addWildcardAllowFrom, mergeAllowFromEntries, - promptAccountId, - resolveAccountIdForConfigure, setTopLevelChannelDmPolicyWithAllowFrom, } from "../channels/plugins/onboarding/helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 2880a60ee58..586ab32b8a6 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -615,21 +615,6 @@ export { } from "../channels/plugins/helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; -export { - addWildcardAllowFrom, - mergeAllowFromEntries, - promptAccountId, - resolveAccountIdForConfigure, - setTopLevelChannelAllowFrom, - setTopLevelChannelDmPolicyWithAllowFrom, - setTopLevelChannelGroupPolicy, -} from "../channels/plugins/onboarding/helpers.js"; -export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js"; - export { createActionGate, jsonResult, @@ -801,6 +786,7 @@ export { resolveDefaultLineAccountId, resolveLineAccount, } from "../line/accounts.js"; +export { lineSetupAdapter, lineSetupWizard } from "../../extensions/line/src/setup-surface.js"; export { LineConfigSchema } from "../line/config-schema.js"; export type { LineConfig, diff --git a/src/plugin-sdk/mattermost.ts b/src/plugin-sdk/mattermost.ts index 54cf2a1bd2f..6cfeeacd918 100644 --- a/src/plugin-sdk/mattermost.ts +++ b/src/plugin-sdk/mattermost.ts @@ -28,13 +28,10 @@ export { export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; -export type { ChannelOnboardingAdapter } from "../channels/plugins/onboarding-types.js"; export { buildSingleChannelSecretPromptState, - promptAccountId, promptSingleChannelSecretInput, runSingleChannelSecretStep, - resolveAccountIdForConfigure, } from "../channels/plugins/onboarding/helpers.js"; export { applyAccountNameToChannelSection, diff --git a/src/plugin-sdk/nextcloud-talk.ts b/src/plugin-sdk/nextcloud-talk.ts index 7e2434914bb..f0d2e1de29d 100644 --- a/src/plugin-sdk/nextcloud-talk.ts +++ b/src/plugin-sdk/nextcloud-talk.ts @@ -21,10 +21,8 @@ export { buildSingleChannelSecretPromptState, addWildcardAllowFrom, mergeAllowFromEntries, - promptAccountId, promptSingleChannelSecretInput, runSingleChannelSecretStep, - resolveAccountIdForConfigure, setTopLevelChannelDmPolicyWithAllowFrom, } from "../channels/plugins/onboarding/helpers.js"; export { From 067215629f7148f0d2aef804d46375e0675820a1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:15:32 -0700 Subject: [PATCH 122/943] Telegram: split setup adapter helpers --- extensions/telegram/src/channel.ts | 3 +- extensions/telegram/src/setup-core.ts | 191 ++++++++++++++++++++++ extensions/telegram/src/setup-surface.ts | 197 ++--------------------- src/plugin-sdk/index.ts | 6 +- src/plugin-sdk/telegram.ts | 6 +- 5 files changed, 208 insertions(+), 195 deletions(-) create mode 100644 extensions/telegram/src/setup-core.ts diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 51dc7811764..4b648b667e6 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -42,7 +42,8 @@ import { resolveOutboundSendDep, } from "../../../src/infra/outbound/send-deps.js"; import { getTelegramRuntime } from "./runtime.js"; -import { telegramSetupAdapter, telegramSetupWizard } from "./setup-surface.js"; +import { telegramSetupAdapter } from "./setup-core.js"; +import { telegramSetupWizard } from "./setup-surface.js"; type TelegramSendFn = ReturnType< typeof getTelegramRuntime diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts new file mode 100644 index 00000000000..fe9c9993035 --- /dev/null +++ b/extensions/telegram/src/setup-core.ts @@ -0,0 +1,191 @@ +import { + patchChannelConfigForAccount, + splitOnboardingEntries, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { formatCliCommand } from "../../../src/cli/command-format.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js"; +import { fetchTelegramChatId } from "./api-fetch.js"; + +const channel = "telegram" as const; + +export const TELEGRAM_TOKEN_HELP_LINES = [ + "1) Open Telegram and chat with @BotFather", + "2) Run /newbot (or /mybots)", + "3) Copy the token (looks like 123456:ABC...)", + "Tip: you can also set TELEGRAM_BOT_TOKEN in your env.", + `Docs: ${formatDocsLink("/telegram")}`, + "Website: https://openclaw.ai", +]; + +export const TELEGRAM_USER_ID_HELP_LINES = [ + `1) DM your bot, then read from.id in \`${formatCliCommand("openclaw logs --follow")}\` (safest)`, + "2) Or call https://api.telegram.org/bot/getUpdates and read message.from.id", + "3) Third-party: DM @userinfobot or @getidsbot", + `Docs: ${formatDocsLink("/telegram")}`, + "Website: https://openclaw.ai", +]; + +export function normalizeTelegramAllowFromInput(raw: string): string { + return raw + .trim() + .replace(/^(telegram|tg):/i, "") + .trim(); +} + +export function parseTelegramAllowFromId(raw: string): string | null { + const stripped = normalizeTelegramAllowFromInput(raw); + return /^\d+$/.test(stripped) ? stripped : null; +} + +export async function resolveTelegramAllowFromEntries(params: { + entries: string[]; + credentialValue?: string; +}) { + return await Promise.all( + params.entries.map(async (entry) => { + const numericId = parseTelegramAllowFromId(entry); + if (numericId) { + return { input: entry, resolved: true, id: numericId }; + } + const stripped = normalizeTelegramAllowFromInput(entry); + if (!stripped || !params.credentialValue?.trim()) { + return { input: entry, resolved: false, id: null }; + } + const username = stripped.startsWith("@") ? stripped : `@${stripped}`; + const id = await fetchTelegramChatId({ + token: params.credentialValue, + chatId: username, + }); + return { input: entry, resolved: Boolean(id), id }; + }), + ); +} + +export async function promptTelegramAllowFromForAccount(params: { + cfg: OpenClawConfig; + prompter: Parameters< + NonNullable< + import("../../../src/channels/plugins/onboarding-types.js").ChannelOnboardingDmPolicy["promptAllowFrom"] + > + >[0]["prompter"]; + accountId?: string; +}) { + const accountId = params.accountId ?? resolveDefaultTelegramAccountId(params.cfg); + const resolved = resolveTelegramAccount({ cfg: params.cfg, accountId }); + await params.prompter.note(TELEGRAM_USER_ID_HELP_LINES.join("\n"), "Telegram user id"); + if (!resolved.token?.trim()) { + await params.prompter.note( + "Telegram token missing; username lookup is unavailable.", + "Telegram", + ); + } + const { promptResolvedAllowFrom } = + await import("../../../src/channels/plugins/onboarding/helpers.js"); + const unique = await promptResolvedAllowFrom({ + prompter: params.prompter, + existing: resolved.config.allowFrom ?? [], + token: resolved.token, + message: "Telegram allowFrom (numeric sender id; @username resolves to id)", + placeholder: "@username", + label: "Telegram allowlist", + parseInputs: splitOnboardingEntries, + parseId: parseTelegramAllowFromId, + invalidWithoutTokenNote: + "Telegram token missing; use numeric sender ids (usernames require a bot token).", + resolveEntries: async ({ entries, token }) => + resolveTelegramAllowFromEntries({ + credentialValue: token, + entries, + }), + }); + return patchChannelConfigForAccount({ + cfg: params.cfg, + channel, + accountId, + patch: { dmPolicy: "allowlist", allowFrom: unique }, + }); +} + +export const telegramSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "TELEGRAM_BOT_TOKEN can only be used for the default account."; + } + if (!input.useEnv && !input.token && !input.tokenFile) { + return "Telegram requires token or --token-file (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + telegram: { + ...next.channels?.telegram, + enabled: true, + ...(input.useEnv + ? {} + : input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + telegram: { + ...next.channels?.telegram, + enabled: true, + accounts: { + ...next.channels?.telegram?.accounts, + [accountId]: { + ...next.channels?.telegram?.accounts?.[accountId], + enabled: true, + ...(input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}), + }, + }, + }, + }, + }; + }, +}; diff --git a/extensions/telegram/src/setup-surface.ts b/extensions/telegram/src/setup-surface.ts index bb46fc963ac..3fcf09ed7db 100644 --- a/extensions/telegram/src/setup-surface.ts +++ b/extensions/telegram/src/setup-surface.ts @@ -1,128 +1,27 @@ import { type ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { patchChannelConfigForAccount, - promptResolvedAllowFrom, - resolveOnboardingAccountId, setChannelDmPolicyWithAllowFrom, setOnboardingChannelEnabled, splitOnboardingEntries, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import { formatCliCommand } from "../../../src/cli/command-format.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { inspectTelegramAccount } from "./account-inspect.js"; +import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js"; import { - listTelegramAccountIds, - resolveDefaultTelegramAccountId, - resolveTelegramAccount, -} from "./accounts.js"; -import { fetchTelegramChatId } from "./api-fetch.js"; + parseTelegramAllowFromId, + promptTelegramAllowFromForAccount, + resolveTelegramAllowFromEntries, + TELEGRAM_TOKEN_HELP_LINES, + TELEGRAM_USER_ID_HELP_LINES, + telegramSetupAdapter, +} from "./setup-core.js"; const channel = "telegram" as const; -const TELEGRAM_TOKEN_HELP_LINES = [ - "1) Open Telegram and chat with @BotFather", - "2) Run /newbot (or /mybots)", - "3) Copy the token (looks like 123456:ABC...)", - "Tip: you can also set TELEGRAM_BOT_TOKEN in your env.", - `Docs: ${formatDocsLink("/telegram")}`, - "Website: https://openclaw.ai", -]; - -const TELEGRAM_USER_ID_HELP_LINES = [ - `1) DM your bot, then read from.id in \`${formatCliCommand("openclaw logs --follow")}\` (safest)`, - "2) Or call https://api.telegram.org/bot/getUpdates and read message.from.id", - "3) Third-party: DM @userinfobot or @getidsbot", - `Docs: ${formatDocsLink("/telegram")}`, - "Website: https://openclaw.ai", -]; - -export function normalizeTelegramAllowFromInput(raw: string): string { - return raw - .trim() - .replace(/^(telegram|tg):/i, "") - .trim(); -} - -export function parseTelegramAllowFromId(raw: string): string | null { - const stripped = normalizeTelegramAllowFromInput(raw); - return /^\d+$/.test(stripped) ? stripped : null; -} - -async function resolveTelegramAllowFromEntries(params: { - entries: string[]; - credentialValue?: string; -}) { - return await Promise.all( - params.entries.map(async (entry) => { - const numericId = parseTelegramAllowFromId(entry); - if (numericId) { - return { input: entry, resolved: true, id: numericId }; - } - const stripped = normalizeTelegramAllowFromInput(entry); - if (!stripped || !params.credentialValue?.trim()) { - return { input: entry, resolved: false, id: null }; - } - const username = stripped.startsWith("@") ? stripped : `@${stripped}`; - const id = await fetchTelegramChatId({ - token: params.credentialValue, - chatId: username, - }); - return { input: entry, resolved: Boolean(id), id }; - }), - ); -} - -async function promptTelegramAllowFromForAccount(params: { - cfg: OpenClawConfig; - prompter: Parameters>[0]["prompter"]; - accountId?: string; -}): Promise { - const accountId = resolveOnboardingAccountId({ - accountId: params.accountId, - defaultAccountId: resolveDefaultTelegramAccountId(params.cfg), - }); - const resolved = resolveTelegramAccount({ cfg: params.cfg, accountId }); - await params.prompter.note(TELEGRAM_USER_ID_HELP_LINES.join("\n"), "Telegram user id"); - if (!resolved.token?.trim()) { - await params.prompter.note( - "Telegram token missing; username lookup is unavailable.", - "Telegram", - ); - } - const unique = await promptResolvedAllowFrom({ - prompter: params.prompter, - existing: resolved.config.allowFrom ?? [], - token: resolved.token, - message: "Telegram allowFrom (numeric sender id; @username resolves to id)", - placeholder: "@username", - label: "Telegram allowlist", - parseInputs: splitOnboardingEntries, - parseId: parseTelegramAllowFromId, - invalidWithoutTokenNote: - "Telegram token missing; use numeric sender ids (usernames require a bot token).", - resolveEntries: async ({ entries, token }) => - resolveTelegramAllowFromEntries({ - credentialValue: token, - entries, - }), - }); - return patchChannelConfigForAccount({ - cfg: params.cfg, - channel, - accountId, - patch: { dmPolicy: "allowlist", allowFrom: unique }, - }); -} - const dmPolicy: ChannelOnboardingDmPolicy = { label: "Telegram", channel, @@ -138,82 +37,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptTelegramAllowFromForAccount, }; -export const telegramSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "TELEGRAM_BOT_TOKEN can only be used for the default account."; - } - if (!input.useEnv && !input.token && !input.tokenFile) { - return "Telegram requires token or --token-file (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - telegram: { - ...next.channels?.telegram, - enabled: true, - ...(input.useEnv - ? {} - : input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { botToken: input.token } - : {}), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - telegram: { - ...next.channels?.telegram, - enabled: true, - accounts: { - ...next.channels?.telegram?.accounts, - [accountId]: { - ...next.channels?.telegram?.accounts?.[accountId], - enabled: true, - ...(input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { botToken: input.token } - : {}), - }, - }, - }, - }, - }; - }, -}; - export const telegramSetupWizard: ChannelSetupWizard = { channel, status: { @@ -284,3 +107,5 @@ export const telegramSetupWizard: ChannelSetupWizard = { dmPolicy, disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), }; + +export { parseTelegramAllowFromId, telegramSetupAdapter }; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 586ab32b8a6..699d0778522 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -743,10 +743,8 @@ export { } from "../../extensions/telegram/src/accounts.js"; export { inspectTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; export type { InspectedTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; -export { - telegramSetupAdapter, - telegramSetupWizard, -} from "../../extensions/telegram/src/setup-surface.js"; +export { telegramSetupWizard } from "../../extensions/telegram/src/setup-surface.js"; +export { telegramSetupAdapter } from "../../extensions/telegram/src/setup-core.js"; export { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget, diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 64502bf2703..7504994f70a 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -64,10 +64,8 @@ export { resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, } from "../channels/plugins/group-mentions.js"; -export { - telegramSetupAdapter, - telegramSetupWizard, -} from "../../extensions/telegram/src/setup-surface.js"; +export { telegramSetupWizard } from "../../extensions/telegram/src/setup-surface.js"; +export { telegramSetupAdapter } from "../../extensions/telegram/src/setup-core.js"; export { TelegramConfigSchema } from "../config/zod-schema.providers-core.js"; export { buildTokenChannelStatusSummary } from "./status-helpers.js"; From c6950367fb8070c05e10d0f6f5f9b96fd54025f3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 02:18:55 +0000 Subject: [PATCH 123/943] fix: allow plugin package id hints --- src/plugins/manifest-registry.test.ts | 17 +++++++++++++++++ src/plugins/manifest-registry.ts | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 6f4c0353330..84e5f13fd98 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -331,6 +331,23 @@ describe("loadPluginManifestRegistry", () => { ); }); + it("accepts plugin-style id hints without warning", () => { + const dir = makeTempDir(); + writeManifest(dir, { id: "brave", configSchema: { type: "object" } }); + + const registry = loadRegistry([ + createPluginCandidate({ + idHint: "brave-plugin", + rootDir: dir, + origin: "bundled", + }), + ]); + + expect(registry.diagnostics.some((diag) => diag.message.includes("plugin id mismatch"))).toBe( + false, + ); + }); + it("still warns for unrelated id hint mismatches", () => { const dir = makeTempDir(); writeManifest(dir, { id: "openai", configSchema: { type: "object" } }); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 2c24b87f541..4f43cff8e2b 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -131,7 +131,7 @@ function isCompatiblePluginIdHint(idHint: string | undefined, manifestId: string if (normalizedHint === manifestId) { return true; } - return normalizedHint === `${manifestId}-provider`; + return normalizedHint === `${manifestId}-provider` || normalizedHint === `${manifestId}-plugin`; } function buildRecord(params: { From c89527f38950a8bceddd0c6f779dbacfe5251ae0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:19:22 -0700 Subject: [PATCH 124/943] Tlon: split setup adapter helpers --- extensions/tlon/src/channel.ts | 3 +- extensions/tlon/src/setup-core.ts | 101 +++++++++++++++++++++++++++ extensions/tlon/src/setup-surface.ts | 100 +------------------------- src/plugin-sdk/tlon.ts | 3 +- 4 files changed, 108 insertions(+), 99 deletions(-) create mode 100644 extensions/tlon/src/setup-core.ts diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 7a460a6adb8..9282fcf92f9 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -7,7 +7,8 @@ import type { } from "openclaw/plugin-sdk/tlon"; import { tlonChannelConfigSchema } from "./config-schema.js"; import { monitorTlonProvider } from "./monitor/index.js"; -import { tlonSetupAdapter, tlonSetupWizard } from "./setup-surface.js"; +import { tlonSetupAdapter } from "./setup-core.js"; +import { tlonSetupWizard } from "./setup-surface.js"; import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js"; import { resolveTlonAccount, listTlonAccountIds } from "./types.js"; import { authenticate } from "./urbit/auth.js"; diff --git a/extensions/tlon/src/setup-core.ts b/extensions/tlon/src/setup-core.ts new file mode 100644 index 00000000000..a237a813edf --- /dev/null +++ b/extensions/tlon/src/setup-core.ts @@ -0,0 +1,101 @@ +import { + applyAccountNameToChannelSection, + patchScopedAccountConfig, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { buildTlonAccountFields } from "./account-fields.js"; +import { resolveTlonAccount } from "./types.js"; + +const channel = "tlon" as const; + +export type TlonSetupInput = ChannelSetupInput & { + ship?: string; + url?: string; + code?: string; + allowPrivateNetwork?: boolean; + groupChannels?: string[]; + dmAllowlist?: string[]; + autoDiscoverChannels?: boolean; + ownerShip?: string; +}; + +export function applyTlonSetupConfig(params: { + cfg: OpenClawConfig; + accountId: string; + input: TlonSetupInput; +}): OpenClawConfig { + const { cfg, accountId, input } = params; + const useDefault = accountId === DEFAULT_ACCOUNT_ID; + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const base = namedConfig.channels?.tlon ?? {}; + const payload = buildTlonAccountFields(input); + + if (useDefault) { + return { + ...namedConfig, + channels: { + ...namedConfig.channels, + tlon: { + ...base, + enabled: true, + ...payload, + }, + }, + }; + } + + return patchScopedAccountConfig({ + cfg: namedConfig, + channelKey: channel, + accountId, + patch: { enabled: base.enabled ?? true }, + accountPatch: { + enabled: true, + ...payload, + }, + ensureChannelEnabled: false, + ensureAccountEnabled: false, + }); +} + +export const tlonSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ cfg, accountId, input }) => { + const setupInput = input as TlonSetupInput; + const resolved = resolveTlonAccount(cfg, accountId ?? undefined); + const ship = setupInput.ship?.trim() || resolved.ship; + const url = setupInput.url?.trim() || resolved.url; + const code = setupInput.code?.trim() || resolved.code; + if (!ship) { + return "Tlon requires --ship."; + } + if (!url) { + return "Tlon requires --url."; + } + if (!code) { + return "Tlon requires --code."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => + applyTlonSetupConfig({ + cfg, + accountId, + input: input as TlonSetupInput, + }), +}; diff --git a/extensions/tlon/src/setup-surface.ts b/extensions/tlon/src/setup-surface.ts index 4cf0d006ebd..ec6258277bd 100644 --- a/extensions/tlon/src/setup-surface.ts +++ b/extensions/tlon/src/setup-surface.ts @@ -1,31 +1,13 @@ -import { - applyAccountNameToChannelSection, - patchScopedAccountConfig, -} from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; -import { buildTlonAccountFields } from "./account-fields.js"; +import { applyTlonSetupConfig, type TlonSetupInput, tlonSetupAdapter } from "./setup-core.js"; import { normalizeShip } from "./targets.js"; import { listTlonAccountIds, resolveTlonAccount, type TlonResolvedAccount } from "./types.js"; import { isBlockedUrbitHostname, validateUrbitBaseUrl } from "./urbit/base-url.js"; const channel = "tlon" as const; -type TlonSetupInput = ChannelSetupInput & { - ship?: string; - url?: string; - code?: string; - allowPrivateNetwork?: boolean; - groupChannels?: string[]; - dmAllowlist?: string[]; - autoDiscoverChannels?: boolean; - ownerShip?: string; -}; - function isConfigured(account: TlonResolvedAccount): boolean { return Boolean(account.ship && account.url && account.code); } @@ -37,83 +19,7 @@ function parseList(value: string): string[] { .filter(Boolean); } -function applyTlonSetupConfig(params: { - cfg: OpenClawConfig; - accountId: string; - input: TlonSetupInput; -}): OpenClawConfig { - const { cfg, accountId, input } = params; - const useDefault = accountId === DEFAULT_ACCOUNT_ID; - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const base = namedConfig.channels?.tlon ?? {}; - const payload = buildTlonAccountFields(input); - - if (useDefault) { - return { - ...namedConfig, - channels: { - ...namedConfig.channels, - tlon: { - ...base, - enabled: true, - ...payload, - }, - }, - }; - } - - return patchScopedAccountConfig({ - cfg: namedConfig, - channelKey: channel, - accountId, - patch: { enabled: base.enabled ?? true }, - accountPatch: { - enabled: true, - ...payload, - }, - ensureChannelEnabled: false, - ensureAccountEnabled: false, - }); -} - -export const tlonSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ cfg, accountId, input }) => { - const setupInput = input as TlonSetupInput; - const resolved = resolveTlonAccount(cfg, accountId ?? undefined); - const ship = setupInput.ship?.trim() || resolved.ship; - const url = setupInput.url?.trim() || resolved.url; - const code = setupInput.code?.trim() || resolved.code; - if (!ship) { - return "Tlon requires --ship."; - } - if (!url) { - return "Tlon requires --url."; - } - if (!code) { - return "Tlon requires --code."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => - applyTlonSetupConfig({ - cfg, - accountId, - input: input as TlonSetupInput, - }), -}; +export { tlonSetupAdapter } from "./setup-core.js"; export const tlonSetupWizard: ChannelSetupWizard = { channel, diff --git a/src/plugin-sdk/tlon.ts b/src/plugin-sdk/tlon.ts index 9a39493cac2..f1415103398 100644 --- a/src/plugin-sdk/tlon.ts +++ b/src/plugin-sdk/tlon.ts @@ -28,4 +28,5 @@ export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { createLoggerBackedRuntime } from "./runtime.js"; -export { tlonSetupAdapter, tlonSetupWizard } from "../../extensions/tlon/src/setup-surface.js"; +export { tlonSetupAdapter } from "../../extensions/tlon/src/setup-core.js"; +export { tlonSetupWizard } from "../../extensions/tlon/src/setup-surface.js"; From 6a2efa541be1c5ba46045535110cd2c6d118d907 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:21:40 -0700 Subject: [PATCH 125/943] LINE: split setup adapter helpers --- extensions/line/src/channel.ts | 3 +- extensions/line/src/setup-core.ts | 162 ++++++++++++++++++++++++++ extensions/line/src/setup-surface.ts | 165 ++------------------------- src/plugin-sdk/line.ts | 3 +- 4 files changed, 175 insertions(+), 158 deletions(-) create mode 100644 extensions/line/src/setup-core.ts diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index 4c2b51cd6d0..b184ebe8482 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -20,7 +20,8 @@ import { type ResolvedLineAccount, } from "openclaw/plugin-sdk/line"; import { getLineRuntime } from "./runtime.js"; -import { lineSetupAdapter, lineSetupWizard } from "./setup-surface.js"; +import { lineSetupAdapter } from "./setup-core.js"; +import { lineSetupWizard } from "./setup-surface.js"; // LINE channel metadata const meta = { diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts new file mode 100644 index 00000000000..324197c70af --- /dev/null +++ b/extensions/line/src/setup-core.ts @@ -0,0 +1,162 @@ +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { + listLineAccountIds, + normalizeAccountId, + resolveLineAccount, +} from "../../../src/line/accounts.js"; +import type { LineConfig } from "../../../src/line/types.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; + +const channel = "line" as const; + +export function patchLineAccountConfig(params: { + cfg: OpenClawConfig; + accountId: string; + patch: Record; + clearFields?: string[]; + enabled?: boolean; +}): OpenClawConfig { + const accountId = normalizeAccountId(params.accountId); + const lineConfig = ((params.cfg.channels?.line ?? {}) as LineConfig) ?? {}; + const clearFields = params.clearFields ?? []; + + if (accountId === DEFAULT_ACCOUNT_ID) { + const nextLine = { ...lineConfig } as Record; + for (const field of clearFields) { + delete nextLine[field]; + } + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + line: { + ...nextLine, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }, + }, + }; + } + + const nextAccount = { + ...(lineConfig.accounts?.[accountId] ?? {}), + } as Record; + for (const field of clearFields) { + delete nextAccount[field]; + } + + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + line: { + ...lineConfig, + ...(params.enabled ? { enabled: true } : {}), + accounts: { + ...lineConfig.accounts, + [accountId]: { + ...nextAccount, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }, + }, + }, + }, + }; +} + +export function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean { + const resolved = resolveLineAccount({ cfg, accountId }); + return Boolean(resolved.channelAccessToken.trim() && resolved.channelSecret.trim()); +} + +export function parseLineAllowFromId(raw: string): string | null { + const trimmed = raw.trim().replace(/^line:(?:user:)?/i, ""); + if (!/^U[a-f0-9]{32}$/i.test(trimmed)) { + return null; + } + return trimmed; +} + +export const lineSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + patchLineAccountConfig({ + cfg, + accountId, + patch: name?.trim() ? { name: name.trim() } : {}, + }), + validateInput: ({ accountId, input }) => { + const typedInput = input as { + useEnv?: boolean; + channelAccessToken?: string; + channelSecret?: string; + tokenFile?: string; + secretFile?: string; + }; + if (typedInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account."; + } + if (!typedInput.useEnv && !typedInput.channelAccessToken && !typedInput.tokenFile) { + return "LINE requires channelAccessToken or --token-file (or --use-env)."; + } + if (!typedInput.useEnv && !typedInput.channelSecret && !typedInput.secretFile) { + return "LINE requires channelSecret or --secret-file (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const typedInput = input as { + useEnv?: boolean; + channelAccessToken?: string; + channelSecret?: string; + tokenFile?: string; + secretFile?: string; + }; + const normalizedAccountId = normalizeAccountId(accountId); + if (normalizedAccountId === DEFAULT_ACCOUNT_ID) { + return patchLineAccountConfig({ + cfg, + accountId: normalizedAccountId, + enabled: true, + clearFields: typedInput.useEnv + ? ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"] + : undefined, + patch: typedInput.useEnv + ? {} + : { + ...(typedInput.tokenFile + ? { tokenFile: typedInput.tokenFile } + : typedInput.channelAccessToken + ? { channelAccessToken: typedInput.channelAccessToken } + : {}), + ...(typedInput.secretFile + ? { secretFile: typedInput.secretFile } + : typedInput.channelSecret + ? { channelSecret: typedInput.channelSecret } + : {}), + }, + }); + } + return patchLineAccountConfig({ + cfg, + accountId: normalizedAccountId, + enabled: true, + patch: { + ...(typedInput.tokenFile + ? { tokenFile: typedInput.tokenFile } + : typedInput.channelAccessToken + ? { channelAccessToken: typedInput.channelAccessToken } + : {}), + ...(typedInput.secretFile + ? { secretFile: typedInput.secretFile } + : typedInput.channelSecret + ? { channelSecret: typedInput.channelSecret } + : {}), + }, + }); + }, +}; + +export { listLineAccountIds }; diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index 1b7a22dfb11..8c1dca21562 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -5,16 +5,16 @@ import { splitOnboardingEntries, } from "../../../src/channels/plugins/onboarding/helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { - listLineAccountIds, - normalizeAccountId, - resolveLineAccount, -} from "../../../src/line/accounts.js"; -import type { LineConfig } from "../../../src/line/types.js"; +import { resolveLineAccount } from "../../../src/line/accounts.js"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; +import { + isLineConfigured, + lineSetupAdapter, + listLineAccountIds, + parseLineAllowFromId, + patchLineAccountConfig, +} from "./setup-core.js"; const channel = "line" as const; @@ -36,75 +36,6 @@ const LINE_ALLOW_FROM_HELP_LINES = [ `Docs: ${formatDocsLink("/channels/line", "channels/line")}`, ]; -function patchLineAccountConfig(params: { - cfg: OpenClawConfig; - accountId: string; - patch: Record; - clearFields?: string[]; - enabled?: boolean; -}): OpenClawConfig { - const accountId = normalizeAccountId(params.accountId); - const lineConfig = ((params.cfg.channels?.line ?? {}) as LineConfig) ?? {}; - const clearFields = params.clearFields ?? []; - - if (accountId === DEFAULT_ACCOUNT_ID) { - const nextLine = { ...lineConfig } as Record; - for (const field of clearFields) { - delete nextLine[field]; - } - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - line: { - ...nextLine, - ...(params.enabled ? { enabled: true } : {}), - ...params.patch, - }, - }, - }; - } - - const nextAccount = { - ...(lineConfig.accounts?.[accountId] ?? {}), - } as Record; - for (const field of clearFields) { - delete nextAccount[field]; - } - - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - line: { - ...lineConfig, - ...(params.enabled ? { enabled: true } : {}), - accounts: { - ...lineConfig.accounts, - [accountId]: { - ...nextAccount, - ...(params.enabled ? { enabled: true } : {}), - ...params.patch, - }, - }, - }, - }, - }; -} - -function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean { - const resolved = resolveLineAccount({ cfg, accountId }); - return Boolean(resolved.channelAccessToken.trim() && resolved.channelSecret.trim()); -} - -function parseLineAllowFromId(raw: string): string | null { - const trimmed = raw.trim().replace(/^line:(?:user:)?/i, ""); - if (!/^U[a-f0-9]{32}$/i.test(trimmed)) { - return null; - } - return trimmed; -} - const lineDmPolicy: ChannelOnboardingDmPolicy = { label: "LINE", channel, @@ -119,85 +50,7 @@ const lineDmPolicy: ChannelOnboardingDmPolicy = { }), }; -export const lineSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - patchLineAccountConfig({ - cfg, - accountId, - patch: name?.trim() ? { name: name.trim() } : {}, - }), - validateInput: ({ accountId, input }) => { - const typedInput = input as { - useEnv?: boolean; - channelAccessToken?: string; - channelSecret?: string; - tokenFile?: string; - secretFile?: string; - }; - if (typedInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account."; - } - if (!typedInput.useEnv && !typedInput.channelAccessToken && !typedInput.tokenFile) { - return "LINE requires channelAccessToken or --token-file (or --use-env)."; - } - if (!typedInput.useEnv && !typedInput.channelSecret && !typedInput.secretFile) { - return "LINE requires channelSecret or --secret-file (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const typedInput = input as { - useEnv?: boolean; - channelAccessToken?: string; - channelSecret?: string; - tokenFile?: string; - secretFile?: string; - }; - const normalizedAccountId = normalizeAccountId(accountId); - if (normalizedAccountId === DEFAULT_ACCOUNT_ID) { - return patchLineAccountConfig({ - cfg, - accountId: normalizedAccountId, - enabled: true, - clearFields: typedInput.useEnv - ? ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"] - : undefined, - patch: typedInput.useEnv - ? {} - : { - ...(typedInput.tokenFile - ? { tokenFile: typedInput.tokenFile } - : typedInput.channelAccessToken - ? { channelAccessToken: typedInput.channelAccessToken } - : {}), - ...(typedInput.secretFile - ? { secretFile: typedInput.secretFile } - : typedInput.channelSecret - ? { channelSecret: typedInput.channelSecret } - : {}), - }, - }); - } - return patchLineAccountConfig({ - cfg, - accountId: normalizedAccountId, - enabled: true, - patch: { - ...(typedInput.tokenFile - ? { tokenFile: typedInput.tokenFile } - : typedInput.channelAccessToken - ? { channelAccessToken: typedInput.channelAccessToken } - : {}), - ...(typedInput.secretFile - ? { secretFile: typedInput.secretFile } - : typedInput.channelSecret - ? { channelSecret: typedInput.channelSecret } - : {}), - }, - }); - }, -}; +export { lineSetupAdapter } from "./setup-core.js"; export const lineSetupWizard: ChannelSetupWizard = { channel, diff --git a/src/plugin-sdk/line.ts b/src/plugin-sdk/line.ts index d0c6ffcaf86..6022c2ea318 100644 --- a/src/plugin-sdk/line.ts +++ b/src/plugin-sdk/line.ts @@ -27,7 +27,8 @@ export { buildTokenChannelStatusSummary, } from "./status-helpers.js"; -export { lineSetupAdapter, lineSetupWizard } from "../../extensions/line/src/setup-surface.js"; +export { lineSetupAdapter } from "../../extensions/line/src/setup-core.js"; +export { lineSetupWizard } from "../../extensions/line/src/setup-surface.js"; export { LineConfigSchema } from "../line/config-schema.js"; export type { LineChannelData, LineConfig, ResolvedLineAccount } from "../line/types.js"; export { From 38abdea8ce7e7f94b818f046068a35e1d0d38d82 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 02:23:21 +0000 Subject: [PATCH 126/943] fix: restore ci type checks --- extensions/line/src/setup-surface.ts | 68 ++++++++++++++++++++++++++++ scripts/lib/plugin-sdk-entries.d.mts | 13 ++++++ src/plugin-sdk/index.test.ts | 2 +- src/plugin-sdk/subpaths.test.ts | 2 +- 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 scripts/lib/plugin-sdk-entries.d.mts diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index 8c1dca21562..688cbf057e5 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -36,6 +36,74 @@ const LINE_ALLOW_FROM_HELP_LINES = [ `Docs: ${formatDocsLink("/channels/line", "channels/line")}`, ]; +function patchLineAccountConfig(params: { + cfg: OpenClawConfig; + accountId: string; + patch: Record; + clearFields?: string[]; + enabled?: boolean; +}): OpenClawConfig { + const accountId = normalizeAccountId(params.accountId); + const lineConfig = (params.cfg.channels?.line ?? {}) as LineConfig; + const clearFields = params.clearFields ?? []; + + if (accountId === DEFAULT_ACCOUNT_ID) { + const nextLine = { ...lineConfig } as Record; + for (const field of clearFields) { + delete nextLine[field]; + } + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + line: { + ...nextLine, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }, + }, + }; + } + + const nextAccount = { + ...(lineConfig.accounts?.[accountId] ?? {}), + } as Record; + for (const field of clearFields) { + delete nextAccount[field]; + } + + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + line: { + ...lineConfig, + ...(params.enabled ? { enabled: true } : {}), + accounts: { + ...lineConfig.accounts, + [accountId]: { + ...nextAccount, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }, + }, + }, + }, + }; +} + +function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean { + const resolved = resolveLineAccount({ cfg, accountId }); + return Boolean(resolved.channelAccessToken.trim() && resolved.channelSecret.trim()); +} + +function parseLineAllowFromId(raw: string): string | null { + const trimmed = raw.trim().replace(/^line:(?:user:)?/i, ""); + if (!/^U[a-f0-9]{32}$/i.test(trimmed)) { + return null; + } + return trimmed; +} const lineDmPolicy: ChannelOnboardingDmPolicy = { label: "LINE", channel, diff --git a/scripts/lib/plugin-sdk-entries.d.mts b/scripts/lib/plugin-sdk-entries.d.mts new file mode 100644 index 00000000000..e5d493b3d46 --- /dev/null +++ b/scripts/lib/plugin-sdk-entries.d.mts @@ -0,0 +1,13 @@ +export const pluginSdkEntrypoints: string[]; +export const pluginSdkSubpaths: string[]; + +export function buildPluginSdkEntrySources(): Record; +export function buildPluginSdkSpecifiers(): string[]; +export function buildPluginSdkPackageExports(): Record< + string, + { + types: string; + default: string; + } +>; +export function listPluginSdkDistArtifacts(): string[]; diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts index 4e9a8869849..dd99550b122 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.ts @@ -175,7 +175,7 @@ describe("plugin-sdk exports", () => { const { default: importResults } = await import(pathToFileURL(consumerEntry).href); expect(importResults).toEqual( - Object.fromEntries(pluginSdkSpecifiers.map((specifier) => [specifier, "object"])), + Object.fromEntries(pluginSdkSpecifiers.map((specifier: string) => [specifier, "object"])), ); } finally { await fs.rm(outDir, { recursive: true, force: true }); diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 3315cbe5963..6e4b942b9a9 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -13,7 +13,7 @@ import { pluginSdkSubpaths } from "../../scripts/lib/plugin-sdk-entries.mjs"; const importPluginSdkSubpath = (specifier: string) => import(/* @vite-ignore */ specifier); -const bundledExtensionSubpathLoaders = pluginSdkSubpaths.map((id) => ({ +const bundledExtensionSubpathLoaders = pluginSdkSubpaths.map((id: string) => ({ id, load: () => importPluginSdkSubpath(`openclaw/plugin-sdk/${id}`), })); From c8576ec78bbc95c7a099abfc5419fd057f057d22 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 02:25:02 +0000 Subject: [PATCH 127/943] fix: resolve line setup rebase drift --- extensions/line/src/setup-core.ts | 2 +- extensions/line/src/setup-surface.ts | 69 ---------------------------- 2 files changed, 1 insertion(+), 70 deletions(-) diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts index 324197c70af..67c9c674df5 100644 --- a/extensions/line/src/setup-core.ts +++ b/extensions/line/src/setup-core.ts @@ -18,7 +18,7 @@ export function patchLineAccountConfig(params: { enabled?: boolean; }): OpenClawConfig { const accountId = normalizeAccountId(params.accountId); - const lineConfig = ((params.cfg.channels?.line ?? {}) as LineConfig) ?? {}; + const lineConfig = (params.cfg.channels?.line ?? {}) as LineConfig; const clearFields = params.clearFields ?? []; if (accountId === DEFAULT_ACCOUNT_ID) { diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index 688cbf057e5..37167723cf7 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -10,7 +10,6 @@ import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import { isLineConfigured, - lineSetupAdapter, listLineAccountIds, parseLineAllowFromId, patchLineAccountConfig, @@ -36,74 +35,6 @@ const LINE_ALLOW_FROM_HELP_LINES = [ `Docs: ${formatDocsLink("/channels/line", "channels/line")}`, ]; -function patchLineAccountConfig(params: { - cfg: OpenClawConfig; - accountId: string; - patch: Record; - clearFields?: string[]; - enabled?: boolean; -}): OpenClawConfig { - const accountId = normalizeAccountId(params.accountId); - const lineConfig = (params.cfg.channels?.line ?? {}) as LineConfig; - const clearFields = params.clearFields ?? []; - - if (accountId === DEFAULT_ACCOUNT_ID) { - const nextLine = { ...lineConfig } as Record; - for (const field of clearFields) { - delete nextLine[field]; - } - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - line: { - ...nextLine, - ...(params.enabled ? { enabled: true } : {}), - ...params.patch, - }, - }, - }; - } - - const nextAccount = { - ...(lineConfig.accounts?.[accountId] ?? {}), - } as Record; - for (const field of clearFields) { - delete nextAccount[field]; - } - - return { - ...params.cfg, - channels: { - ...params.cfg.channels, - line: { - ...lineConfig, - ...(params.enabled ? { enabled: true } : {}), - accounts: { - ...lineConfig.accounts, - [accountId]: { - ...nextAccount, - ...(params.enabled ? { enabled: true } : {}), - ...params.patch, - }, - }, - }, - }, - }; -} - -function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean { - const resolved = resolveLineAccount({ cfg, accountId }); - return Boolean(resolved.channelAccessToken.trim() && resolved.channelSecret.trim()); -} - -function parseLineAllowFromId(raw: string): string | null { - const trimmed = raw.trim().replace(/^line:(?:user:)?/i, ""); - if (!/^U[a-f0-9]{32}$/i.test(trimmed)) { - return null; - } - return trimmed; -} const lineDmPolicy: ChannelOnboardingDmPolicy = { label: "LINE", channel, From 6513749ef6d3ffb35ff827ae75991eeb61af4018 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:24:35 -0700 Subject: [PATCH 128/943] Mattermost: split setup adapter helpers --- extensions/mattermost/src/channel.ts | 3 +- extensions/mattermost/src/setup-core.ts | 81 ++++++++++++++++++++ extensions/mattermost/src/setup-surface.ts | 89 ++-------------------- 3 files changed, 90 insertions(+), 83 deletions(-) create mode 100644 extensions/mattermost/src/setup-core.ts diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index b28766d6db9..e8873b93268 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -38,7 +38,8 @@ import { sendMessageMattermost } from "./mattermost/send.js"; import { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js"; import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js"; import { getMattermostRuntime } from "./runtime.js"; -import { mattermostSetupAdapter, mattermostSetupWizard } from "./setup-surface.js"; +import { mattermostSetupAdapter } from "./setup-core.js"; +import { mattermostSetupWizard } from "./setup-surface.js"; const mattermostMessageActions: ChannelMessageActionAdapter = { listActions: ({ cfg }) => { diff --git a/extensions/mattermost/src/setup-core.ts b/extensions/mattermost/src/setup-core.ts new file mode 100644 index 00000000000..946b1af728e --- /dev/null +++ b/extensions/mattermost/src/setup-core.ts @@ -0,0 +1,81 @@ +import { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + DEFAULT_ACCOUNT_ID, + hasConfiguredSecretInput, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + type OpenClawConfig, +} from "openclaw/plugin-sdk/mattermost"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { resolveMattermostAccount, type ResolvedMattermostAccount } from "./mattermost/accounts.js"; +import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; + +const channel = "mattermost" as const; + +export function isMattermostConfigured(account: ResolvedMattermostAccount): boolean { + const tokenConfigured = + Boolean(account.botToken?.trim()) || hasConfiguredSecretInput(account.config.botToken); + return tokenConfigured && Boolean(account.baseUrl); +} + +export function resolveMattermostAccountWithSecrets(cfg: OpenClawConfig, accountId: string) { + return resolveMattermostAccount({ + cfg, + accountId, + allowUnresolvedSecretRef: true, + }); +} + +export const mattermostSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + const token = input.botToken ?? input.token; + const baseUrl = normalizeMattermostBaseUrl(input.httpUrl); + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "Mattermost env vars can only be used for the default account."; + } + if (!input.useEnv && (!token || !baseUrl)) { + return "Mattermost requires --bot-token and --http-url (or --use-env)."; + } + if (input.httpUrl && !baseUrl) { + return "Mattermost --http-url must include a valid base URL."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const token = input.botToken ?? input.token; + const baseUrl = normalizeMattermostBaseUrl(input.httpUrl); + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + return applySetupAccountConfigPatch({ + cfg: next, + channelKey: channel, + accountId, + patch: input.useEnv + ? {} + : { + ...(token ? { botToken: token } : {}), + ...(baseUrl ? { baseUrl } : {}), + }, + }); + }, +}; diff --git a/extensions/mattermost/src/setup-surface.ts b/extensions/mattermost/src/setup-surface.ts index a201a24d82f..2877541bba9 100644 --- a/extensions/mattermost/src/setup-surface.ts +++ b/extensions/mattermost/src/setup-surface.ts @@ -1,90 +1,15 @@ -import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, - DEFAULT_ACCOUNT_ID, - hasConfiguredSecretInput, - migrateBaseNameToDefaultAccount, - normalizeAccountId, - type OpenClawConfig, -} from "openclaw/plugin-sdk/mattermost"; +import { DEFAULT_ACCOUNT_ID, hasConfiguredSecretInput } from "openclaw/plugin-sdk/mattermost"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; +import { listMattermostAccountIds } from "./mattermost/accounts.js"; import { - listMattermostAccountIds, - resolveMattermostAccount, - type ResolvedMattermostAccount, -} from "./mattermost/accounts.js"; -import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; + isMattermostConfigured, + mattermostSetupAdapter, + resolveMattermostAccountWithSecrets, +} from "./setup-core.js"; const channel = "mattermost" as const; - -function isMattermostConfigured(account: ResolvedMattermostAccount): boolean { - const tokenConfigured = - Boolean(account.botToken?.trim()) || hasConfiguredSecretInput(account.config.botToken); - return tokenConfigured && Boolean(account.baseUrl); -} - -function resolveMattermostAccountWithSecrets(cfg: OpenClawConfig, accountId: string) { - return resolveMattermostAccount({ - cfg, - accountId, - allowUnresolvedSecretRef: true, - }); -} - -export const mattermostSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - const token = input.botToken ?? input.token; - const baseUrl = normalizeMattermostBaseUrl(input.httpUrl); - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "Mattermost env vars can only be used for the default account."; - } - if (!input.useEnv && (!token || !baseUrl)) { - return "Mattermost requires --bot-token and --http-url (or --use-env)."; - } - if (input.httpUrl && !baseUrl) { - return "Mattermost --http-url must include a valid base URL."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const token = input.botToken ?? input.token; - const baseUrl = normalizeMattermostBaseUrl(input.httpUrl); - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: channel, - accountId, - patch: input.useEnv - ? {} - : { - ...(token ? { botToken: token } : {}), - ...(baseUrl ? { baseUrl } : {}), - }, - }); - }, -}; +export { mattermostSetupAdapter } from "./setup-core.js"; export const mattermostSetupWizard: ChannelSetupWizard = { channel, From 47a9c1a8934209281f0f10b13c81b5f5cd0d33da Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 02:26:11 +0000 Subject: [PATCH 129/943] refactor: merge minimax bundled plugins --- CHANGELOG.md | 1 + docs/providers/minimax.md | 4 +- docs/tools/plugin.md | 5 +- extensions/minimax-portal-auth/README.md | 33 --- extensions/minimax-portal-auth/index.ts | 163 --------------- .../minimax-portal-auth/openclaw.plugin.json | 9 - extensions/minimax-portal-auth/package.json | 12 -- extensions/minimax/README.md | 37 ++++ extensions/minimax/index.ts | 195 ++++++++++++++++-- .../{minimax-portal-auth => minimax}/oauth.ts | 20 +- extensions/minimax/openclaw.plugin.json | 2 +- extensions/minimax/package.json | 2 +- scripts/check-no-raw-channel-fetch.mjs | 4 +- src/commands/auth-choice.apply.minimax.ts | 4 +- src/config/plugin-auto-enable.test.ts | 19 ++ src/config/plugin-auto-enable.ts | 2 +- src/plugin-sdk/minimax-portal-auth.ts | 4 +- src/plugins/config-state.test.ts | 12 +- src/plugins/config-state.ts | 2 +- src/plugins/providers.ts | 1 - 20 files changed, 258 insertions(+), 273 deletions(-) delete mode 100644 extensions/minimax-portal-auth/README.md delete mode 100644 extensions/minimax-portal-auth/index.ts delete mode 100644 extensions/minimax-portal-auth/openclaw.plugin.json delete mode 100644 extensions/minimax-portal-auth/package.json create mode 100644 extensions/minimax/README.md rename extensions/{minimax-portal-auth => minimax}/oauth.ts (90%) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca1d5cf8998..20d0b32ae92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898. - Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs. - Plugins/providers: move OpenRouter, GitHub Copilot, and OpenAI Codex provider/runtime logic into bundled plugins, including dynamic model fallback, runtime auth exchange, stream wrappers, capability hints, and cache-TTL policy. +- Plugins/MiniMax: merge the bundled MiniMax API and MiniMax OAuth plugin surfaces into a single default-on `minimax` plugin, while keeping legacy `minimax-portal-auth` config ids aliased for compatibility. - Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized. - Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc. diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index 8cdc5b028f6..0d3635352cc 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -42,7 +42,7 @@ MiniMax highlights these improvements in M2.5: Enable the bundled OAuth plugin and authenticate: ```bash -openclaw plugins enable minimax-portal-auth # skip if already loaded. +openclaw plugins enable minimax # skip if already loaded. openclaw gateway restart # restart if gateway is already running openclaw onboard --auth-choice minimax-portal ``` @@ -52,7 +52,7 @@ You will be prompted to select an endpoint: - **Global** - International users (`api.minimax.io`) - **CN** - Users in China (`api.minimaxi.com`) -See [MiniMax OAuth plugin README](https://github.com/openclaw/openclaw/tree/main/extensions/minimax-portal-auth) for details. +See [MiniMax plugin README](https://github.com/openclaw/openclaw/tree/main/extensions/minimax) for details. ### MiniMax M2.5 (API key) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 2a5b5d37006..91613cbe731 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -172,8 +172,7 @@ Important trust note: - Hugging Face provider catalog — bundled as `huggingface` (enabled by default) - Kilo Gateway provider runtime — bundled as `kilocode` (enabled by default) - Kimi Coding provider catalog — bundled as `kimi-coding` (enabled by default) -- MiniMax provider catalog + usage — bundled as `minimax` (enabled by default) -- MiniMax OAuth (provider auth + catalog) — bundled as `minimax-portal-auth` (enabled by default) +- MiniMax provider catalog + usage + OAuth — bundled as `minimax` (enabled by default; owns `minimax` and `minimax-portal`) - Mistral provider capabilities — bundled as `mistral` (enabled by default) - Model Studio provider catalog — bundled as `modelstudio` (enabled by default) - Moonshot provider runtime — bundled as `moonshot` (enabled by default) @@ -664,7 +663,7 @@ Default-on bundled plugin examples: - `kilocode` - `kimi-coding` - `minimax` -- `minimax-portal-auth` +- `minimax` - `modelstudio` - `moonshot` - `nvidia` diff --git a/extensions/minimax-portal-auth/README.md b/extensions/minimax-portal-auth/README.md deleted file mode 100644 index 3c29ab8ac22..00000000000 --- a/extensions/minimax-portal-auth/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# MiniMax OAuth (OpenClaw plugin) - -OAuth provider plugin for **MiniMax** (OAuth). - -## Enable - -Bundled plugins are disabled by default. Enable this one: - -```bash -openclaw plugins enable minimax-portal-auth -``` - -Restart the Gateway after enabling. - -```bash -openclaw gateway restart -``` - -## Authenticate - -```bash -openclaw models auth login --provider minimax-portal --set-default -``` - -You will be prompted to select an endpoint: - -- **Global** - International users, optimized for overseas access (`api.minimax.io`) -- **China** - Optimized for users in China (`api.minimaxi.com`) - -## Notes - -- MiniMax OAuth uses a user-code login flow. -- Currently, OAuth login is supported only for the Coding plan diff --git a/extensions/minimax-portal-auth/index.ts b/extensions/minimax-portal-auth/index.ts deleted file mode 100644 index eda0b72227c..00000000000 --- a/extensions/minimax-portal-auth/index.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { - buildOauthProviderAuthResult, - emptyPluginConfigSchema, - type OpenClawPluginApi, - type ProviderAuthContext, - type ProviderAuthResult, - type ProviderCatalogContext, -} from "openclaw/plugin-sdk/minimax-portal-auth"; -import { ensureAuthProfileStore, listProfilesForProvider } from "../../src/agents/auth-profiles.js"; -import { MINIMAX_OAUTH_MARKER } from "../../src/agents/model-auth-markers.js"; -import { buildMinimaxPortalProvider } from "../../src/agents/models-config.providers.static.js"; -import { loginMiniMaxPortalOAuth, type MiniMaxRegion } from "./oauth.js"; - -const PROVIDER_ID = "minimax-portal"; -const PROVIDER_LABEL = "MiniMax"; -const DEFAULT_MODEL = "MiniMax-M2.5"; -const DEFAULT_BASE_URL_CN = "https://api.minimaxi.com/anthropic"; -const DEFAULT_BASE_URL_GLOBAL = "https://api.minimax.io/anthropic"; - -function getDefaultBaseUrl(region: MiniMaxRegion): string { - return region === "cn" ? DEFAULT_BASE_URL_CN : DEFAULT_BASE_URL_GLOBAL; -} - -function modelRef(modelId: string): string { - return `${PROVIDER_ID}/${modelId}`; -} - -function buildProviderCatalog(params: { baseUrl: string; apiKey: string }) { - return { - ...buildMinimaxPortalProvider(), - baseUrl: params.baseUrl, - apiKey: params.apiKey, - }; -} - -function resolveCatalog(ctx: ProviderCatalogContext) { - const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID]; - const envApiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - const authStore = ensureAuthProfileStore(ctx.agentDir, { - allowKeychainPrompt: false, - }); - const hasProfiles = listProfilesForProvider(authStore, PROVIDER_ID).length > 0; - const explicitApiKey = - typeof explicitProvider?.apiKey === "string" ? explicitProvider.apiKey.trim() : undefined; - const apiKey = envApiKey ?? explicitApiKey ?? (hasProfiles ? MINIMAX_OAUTH_MARKER : undefined); - if (!apiKey) { - return null; - } - - const explicitBaseUrl = - typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : undefined; - - return { - provider: buildProviderCatalog({ - baseUrl: explicitBaseUrl || DEFAULT_BASE_URL_GLOBAL, - apiKey, - }), - }; -} - -function createOAuthHandler(region: MiniMaxRegion) { - const defaultBaseUrl = getDefaultBaseUrl(region); - const regionLabel = region === "cn" ? "CN" : "Global"; - - return async (ctx: ProviderAuthContext): Promise => { - const progress = ctx.prompter.progress(`Starting MiniMax OAuth (${regionLabel})…`); - try { - const result = await loginMiniMaxPortalOAuth({ - openUrl: ctx.openUrl, - note: ctx.prompter.note, - progress, - region, - }); - - progress.stop("MiniMax OAuth complete"); - - if (result.notification_message) { - await ctx.prompter.note(result.notification_message, "MiniMax OAuth"); - } - - const baseUrl = result.resourceUrl || defaultBaseUrl; - - return buildOauthProviderAuthResult({ - providerId: PROVIDER_ID, - defaultModel: modelRef(DEFAULT_MODEL), - access: result.access, - refresh: result.refresh, - expires: result.expires, - configPatch: { - models: { - providers: { - [PROVIDER_ID]: { - baseUrl, - models: [], - }, - }, - }, - agents: { - defaults: { - models: { - [modelRef("MiniMax-M2.5")]: { alias: "minimax-m2.5" }, - [modelRef("MiniMax-M2.5-highspeed")]: { - alias: "minimax-m2.5-highspeed", - }, - [modelRef("MiniMax-M2.5-Lightning")]: { - alias: "minimax-m2.5-lightning", - }, - }, - }, - }, - }, - notes: [ - "MiniMax OAuth tokens auto-refresh. Re-run login if refresh fails or access is revoked.", - `Base URL defaults to ${defaultBaseUrl}. Override models.providers.${PROVIDER_ID}.baseUrl if needed.`, - ...(result.notification_message ? [result.notification_message] : []), - ], - }); - } catch (err) { - const errorMsg = err instanceof Error ? err.message : String(err); - progress.stop(`MiniMax OAuth failed: ${errorMsg}`); - await ctx.prompter.note( - "If OAuth fails, verify your MiniMax account has portal access and try again.", - "MiniMax OAuth", - ); - throw err; - } - }; -} - -const minimaxPortalPlugin = { - id: "minimax-portal-auth", - name: "MiniMax OAuth", - description: "OAuth flow for MiniMax models", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { - api.registerProvider({ - id: PROVIDER_ID, - label: PROVIDER_LABEL, - docsPath: "/providers/minimax", - catalog: { - run: async (ctx: ProviderCatalogContext) => resolveCatalog(ctx), - }, - auth: [ - { - id: "oauth", - label: "MiniMax OAuth (Global)", - hint: "Global endpoint - api.minimax.io", - kind: "device_code", - run: createOAuthHandler("global"), - }, - { - id: "oauth-cn", - label: "MiniMax OAuth (CN)", - hint: "CN endpoint - api.minimaxi.com", - kind: "device_code", - run: createOAuthHandler("cn"), - }, - ], - }); - }, -}; - -export default minimaxPortalPlugin; diff --git a/extensions/minimax-portal-auth/openclaw.plugin.json b/extensions/minimax-portal-auth/openclaw.plugin.json deleted file mode 100644 index 4645b6907eb..00000000000 --- a/extensions/minimax-portal-auth/openclaw.plugin.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": "minimax-portal-auth", - "providers": ["minimax-portal"], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": {} - } -} diff --git a/extensions/minimax-portal-auth/package.json b/extensions/minimax-portal-auth/package.json deleted file mode 100644 index 093d42dad1d..00000000000 --- a/extensions/minimax-portal-auth/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@openclaw/minimax-portal-auth", - "version": "2026.3.14", - "private": true, - "description": "OpenClaw MiniMax Portal OAuth provider plugin", - "type": "module", - "openclaw": { - "extensions": [ - "./index.ts" - ] - } -} diff --git a/extensions/minimax/README.md b/extensions/minimax/README.md new file mode 100644 index 00000000000..e38b7c16c68 --- /dev/null +++ b/extensions/minimax/README.md @@ -0,0 +1,37 @@ +# MiniMax (OpenClaw plugin) + +Bundled MiniMax plugin for both: + +- API-key provider setup (`minimax`) +- Coding Plan OAuth setup (`minimax-portal`) + +## Enable + +```bash +openclaw plugins enable minimax +``` + +Restart the Gateway after enabling. + +```bash +openclaw gateway restart +``` + +## Authenticate + +OAuth: + +```bash +openclaw models auth login --provider minimax-portal --set-default +``` + +API key: + +```bash +openclaw onboard --auth-choice minimax-global-api +``` + +## Notes + +- MiniMax OAuth uses a user-code login flow. +- OAuth currently targets the Coding Plan path. diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index 6585e27d7cf..969868986f0 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -1,35 +1,165 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { buildMinimaxProvider } from "../../src/agents/models-config.providers.static.js"; +import { + buildOauthProviderAuthResult, + emptyPluginConfigSchema, + type OpenClawPluginApi, + type ProviderAuthContext, + type ProviderAuthResult, + type ProviderCatalogContext, +} from "openclaw/plugin-sdk/minimax-portal-auth"; +import { ensureAuthProfileStore, listProfilesForProvider } from "../../src/agents/auth-profiles.js"; +import { MINIMAX_OAUTH_MARKER } from "../../src/agents/model-auth-markers.js"; +import { + buildMinimaxPortalProvider, + buildMinimaxProvider, +} from "../../src/agents/models-config.providers.static.js"; import { fetchMinimaxUsage } from "../../src/infra/provider-usage.fetch.js"; +import { loginMiniMaxPortalOAuth, type MiniMaxRegion } from "./oauth.js"; -const PROVIDER_ID = "minimax"; +const API_PROVIDER_ID = "minimax"; +const PORTAL_PROVIDER_ID = "minimax-portal"; +const PROVIDER_LABEL = "MiniMax"; +const DEFAULT_MODEL = "MiniMax-M2.5"; +const DEFAULT_BASE_URL_CN = "https://api.minimaxi.com/anthropic"; +const DEFAULT_BASE_URL_GLOBAL = "https://api.minimax.io/anthropic"; + +function getDefaultBaseUrl(region: MiniMaxRegion): string { + return region === "cn" ? DEFAULT_BASE_URL_CN : DEFAULT_BASE_URL_GLOBAL; +} + +function modelRef(modelId: string): string { + return `${PORTAL_PROVIDER_ID}/${modelId}`; +} + +function buildPortalProviderCatalog(params: { baseUrl: string; apiKey: string }) { + return { + ...buildMinimaxPortalProvider(), + baseUrl: params.baseUrl, + apiKey: params.apiKey, + }; +} + +function resolveApiCatalog(ctx: ProviderCatalogContext) { + const apiKey = ctx.resolveProviderApiKey(API_PROVIDER_ID).apiKey; + if (!apiKey) { + return null; + } + return { + provider: { + ...buildMinimaxProvider(), + apiKey, + }, + }; +} + +function resolvePortalCatalog(ctx: ProviderCatalogContext) { + const explicitProvider = ctx.config.models?.providers?.[PORTAL_PROVIDER_ID]; + const envApiKey = ctx.resolveProviderApiKey(PORTAL_PROVIDER_ID).apiKey; + const authStore = ensureAuthProfileStore(ctx.agentDir, { + allowKeychainPrompt: false, + }); + const hasProfiles = listProfilesForProvider(authStore, PORTAL_PROVIDER_ID).length > 0; + const explicitApiKey = + typeof explicitProvider?.apiKey === "string" ? explicitProvider.apiKey.trim() : undefined; + const apiKey = envApiKey ?? explicitApiKey ?? (hasProfiles ? MINIMAX_OAUTH_MARKER : undefined); + if (!apiKey) { + return null; + } + + const explicitBaseUrl = + typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : undefined; + + return { + provider: buildPortalProviderCatalog({ + baseUrl: explicitBaseUrl || DEFAULT_BASE_URL_GLOBAL, + apiKey, + }), + }; +} + +function createOAuthHandler(region: MiniMaxRegion) { + const defaultBaseUrl = getDefaultBaseUrl(region); + const regionLabel = region === "cn" ? "CN" : "Global"; + + return async (ctx: ProviderAuthContext): Promise => { + const progress = ctx.prompter.progress(`Starting MiniMax OAuth (${regionLabel})…`); + try { + const result = await loginMiniMaxPortalOAuth({ + openUrl: ctx.openUrl, + note: ctx.prompter.note, + progress, + region, + }); + + progress.stop("MiniMax OAuth complete"); + + if (result.notification_message) { + await ctx.prompter.note(result.notification_message, "MiniMax OAuth"); + } + + const baseUrl = result.resourceUrl || defaultBaseUrl; + + return buildOauthProviderAuthResult({ + providerId: PORTAL_PROVIDER_ID, + defaultModel: modelRef(DEFAULT_MODEL), + access: result.access, + refresh: result.refresh, + expires: result.expires, + configPatch: { + models: { + providers: { + [PORTAL_PROVIDER_ID]: { + baseUrl, + models: [], + }, + }, + }, + agents: { + defaults: { + models: { + [modelRef("MiniMax-M2.5")]: { alias: "minimax-m2.5" }, + [modelRef("MiniMax-M2.5-highspeed")]: { + alias: "minimax-m2.5-highspeed", + }, + [modelRef("MiniMax-M2.5-Lightning")]: { + alias: "minimax-m2.5-lightning", + }, + }, + }, + }, + }, + notes: [ + "MiniMax OAuth tokens auto-refresh. Re-run login if refresh fails or access is revoked.", + `Base URL defaults to ${defaultBaseUrl}. Override models.providers.${PORTAL_PROVIDER_ID}.baseUrl if needed.`, + ...(result.notification_message ? [result.notification_message] : []), + ], + }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + progress.stop(`MiniMax OAuth failed: ${errorMsg}`); + await ctx.prompter.note( + "If OAuth fails, verify your MiniMax account has portal access and try again.", + "MiniMax OAuth", + ); + throw err; + } + }; +} const minimaxPlugin = { - id: PROVIDER_ID, - name: "MiniMax Provider", - description: "Bundled MiniMax provider plugin", + id: API_PROVIDER_ID, + name: "MiniMax", + description: "Bundled MiniMax API-key and OAuth provider plugin", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { api.registerProvider({ - id: PROVIDER_ID, - label: "MiniMax", + id: API_PROVIDER_ID, + label: PROVIDER_LABEL, docsPath: "/providers/minimax", envVars: ["MINIMAX_API_KEY"], auth: [], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - provider: { - ...buildMinimaxProvider(), - apiKey, - }, - }; - }, + run: async (ctx) => resolveApiCatalog(ctx), }, resolveUsageAuth: async (ctx) => { const apiKey = ctx.resolveApiKeyFromConfigAndStore({ @@ -40,6 +170,31 @@ const minimaxPlugin = { fetchUsageSnapshot: async (ctx) => await fetchMinimaxUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), }); + + api.registerProvider({ + id: PORTAL_PROVIDER_ID, + label: PROVIDER_LABEL, + docsPath: "/providers/minimax", + catalog: { + run: async (ctx) => resolvePortalCatalog(ctx), + }, + auth: [ + { + id: "oauth", + label: "MiniMax OAuth (Global)", + hint: "Global endpoint - api.minimax.io", + kind: "device_code", + run: createOAuthHandler("global"), + }, + { + id: "oauth-cn", + label: "MiniMax OAuth (CN)", + hint: "CN endpoint - api.minimaxi.com", + kind: "device_code", + run: createOAuthHandler("cn"), + }, + ], + }); }, }; diff --git a/extensions/minimax-portal-auth/oauth.ts b/extensions/minimax/oauth.ts similarity index 90% rename from extensions/minimax-portal-auth/oauth.ts rename to extensions/minimax/oauth.ts index 5b18c13d3a4..fb405cd5559 100644 --- a/extensions/minimax-portal-auth/oauth.ts +++ b/extensions/minimax/oauth.ts @@ -161,7 +161,7 @@ async function pollOAuthToken(params: { return { status: "error", message: "An error occurred. Please try again later" }; } - if (tokenPayload.status != "success") { + if (tokenPayload.status !== "success") { return { status: "pending", message: "current user code is not authorized" }; } @@ -216,29 +216,17 @@ export async function loginMiniMaxPortalOAuth(params: { region, }); - // // Debug: print poll result - // await params.note( - // `status: ${result.status}` + - // (result.status === "success" ? `\ntoken: ${JSON.stringify(result.token, null, 2)}` : "") + - // (result.status === "error" ? `\nmessage: ${result.message}` : "") + - // (result.status === "pending" && result.message ? `\nmessage: ${result.message}` : ""), - // "MiniMax OAuth Poll Result", - // ); - if (result.status === "success") { return result.token; } if (result.status === "error") { - throw new Error(`MiniMax OAuth failed: ${result.message}`); - } - - if (result.status === "pending") { - pollIntervalMs = Math.min(pollIntervalMs * 1.5, 10000); + throw new Error(result.message); } await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + pollIntervalMs = Math.max(pollIntervalMs, 2000); } - throw new Error("MiniMax OAuth timed out waiting for authorization."); + throw new Error("MiniMax OAuth timed out before authorization completed."); } diff --git a/extensions/minimax/openclaw.plugin.json b/extensions/minimax/openclaw.plugin.json index 01f3e5efbea..32d8be58bf5 100644 --- a/extensions/minimax/openclaw.plugin.json +++ b/extensions/minimax/openclaw.plugin.json @@ -1,6 +1,6 @@ { "id": "minimax", - "providers": ["minimax"], + "providers": ["minimax", "minimax-portal"], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/minimax/package.json b/extensions/minimax/package.json index 6650cf1e456..f6c99e0e756 100644 --- a/extensions/minimax/package.json +++ b/extensions/minimax/package.json @@ -2,7 +2,7 @@ "name": "@openclaw/minimax-provider", "version": "2026.3.14", "private": true, - "description": "OpenClaw MiniMax provider plugin", + "description": "OpenClaw MiniMax provider and OAuth plugin", "type": "module", "openclaw": { "extensions": [ diff --git a/scripts/check-no-raw-channel-fetch.mjs b/scripts/check-no-raw-channel-fetch.mjs index 7b935d183e5..57adb600c81 100644 --- a/scripts/check-no-raw-channel-fetch.mjs +++ b/scripts/check-no-raw-channel-fetch.mjs @@ -24,8 +24,8 @@ const allowedRawFetchCallsites = new Set([ "extensions/mattermost/src/mattermost/client.ts:211", "extensions/mattermost/src/mattermost/monitor.ts:230", "extensions/mattermost/src/mattermost/probe.ts:27", - "extensions/minimax-portal-auth/oauth.ts:71", - "extensions/minimax-portal-auth/oauth.ts:112", + "extensions/minimax/oauth.ts:62", + "extensions/minimax/oauth.ts:93", "extensions/msteams/src/graph.ts:39", "extensions/nextcloud-talk/src/room-info.ts:92", "extensions/nextcloud-talk/src/send.ts:107", diff --git a/src/commands/auth-choice.apply.minimax.ts b/src/commands/auth-choice.apply.minimax.ts index 1a381b908b8..6438b94c043 100644 --- a/src/commands/auth-choice.apply.minimax.ts +++ b/src/commands/auth-choice.apply.minimax.ts @@ -22,7 +22,7 @@ export async function applyAuthChoiceMiniMax( if (params.authChoice === "minimax-global-oauth") { return await applyAuthChoicePluginProvider(params, { authChoice: "minimax-global-oauth", - pluginId: "minimax-portal-auth", + pluginId: "minimax", providerId: "minimax-portal", methodId: "oauth", label: "MiniMax", @@ -32,7 +32,7 @@ export async function applyAuthChoiceMiniMax( if (params.authChoice === "minimax-cn-oauth") { return await applyAuthChoicePluginProvider(params, { authChoice: "minimax-cn-oauth", - pluginId: "minimax-portal-auth", + pluginId: "minimax", providerId: "minimax-portal", methodId: "oauth-cn", label: "MiniMax CN", diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index cae9b4e5c18..8439a2768ec 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -310,6 +310,25 @@ describe("applyPluginAutoEnable", () => { expect(result.config.plugins?.entries?.google?.enabled).toBe(true); }); + it("auto-enables minimax when minimax-portal profiles exist", () => { + const result = applyPluginAutoEnable({ + config: { + auth: { + profiles: { + "minimax-portal:default": { + provider: "minimax-portal", + mode: "oauth", + }, + }, + }, + }, + env: {}, + }); + + expect(result.config.plugins?.entries?.minimax?.enabled).toBe(true); + expect(result.config.plugins?.entries?.["minimax-portal-auth"]).toBeUndefined(); + }); + it("auto-enables acpx plugin when ACP is configured", () => { const result = applyPluginAutoEnable({ config: { diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 72e1dede1ef..2a7524b2558 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -31,7 +31,7 @@ const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [ { pluginId: "google", providerId: "google-gemini-cli" }, { pluginId: "qwen-portal-auth", providerId: "qwen-portal" }, { pluginId: "copilot-proxy", providerId: "copilot-proxy" }, - { pluginId: "minimax-portal-auth", providerId: "minimax-portal" }, + { pluginId: "minimax", providerId: "minimax-portal" }, ]; function hasNonEmptyString(value: unknown): boolean { diff --git a/src/plugin-sdk/minimax-portal-auth.ts b/src/plugin-sdk/minimax-portal-auth.ts index cc41b2cc80d..07aefa0aafa 100644 --- a/src/plugin-sdk/minimax-portal-auth.ts +++ b/src/plugin-sdk/minimax-portal-auth.ts @@ -1,5 +1,5 @@ -// Narrow plugin-sdk surface for the bundled minimax-portal-auth plugin. -// Keep this list additive and scoped to symbols used under extensions/minimax-portal-auth. +// Narrow plugin-sdk surface for MiniMax OAuth helpers used by the bundled minimax plugin. +// Keep this list additive and scoped to MiniMax OAuth support code. export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index 37db8a6efae..c4195a5e6e3 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -80,18 +80,22 @@ describe("normalizePluginsConfig", () => { it("normalizes legacy plugin ids to their merged bundled plugin id", () => { const result = normalizePluginsConfig({ - allow: ["openai-codex"], - deny: ["openai-codex"], + allow: ["openai-codex", "minimax-portal-auth"], + deny: ["openai-codex", "minimax-portal-auth"], entries: { "openai-codex": { enabled: true, }, + "minimax-portal-auth": { + enabled: false, + }, }, }); - expect(result.allow).toEqual(["openai"]); - expect(result.deny).toEqual(["openai"]); + expect(result.allow).toEqual(["openai", "minimax"]); + expect(result.deny).toEqual(["openai", "minimax"]); expect(result.entries.openai?.enabled).toBe(true); + expect(result.entries.minimax?.enabled).toBe(false); }); }); diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index a5860b606e3..493ad885f51 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -33,7 +33,6 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ "kilocode", "kimi-coding", "minimax", - "minimax-portal-auth", "mistral", "modelstudio", "moonshot", @@ -60,6 +59,7 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ const PLUGIN_ID_ALIASES: Readonly> = { "openai-codex": "openai", + "minimax-portal-auth": "minimax", }; function normalizePluginId(id: string): string { diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 4f4216730cf..c1de0680359 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -16,7 +16,6 @@ const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "kilocode", "kimi-coding", "minimax", - "minimax-portal-auth", "mistral", "modelstudio", "moonshot", From bcdbd03579e4fdb8c1c411f882e590dec111ef0f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 02:26:15 +0000 Subject: [PATCH 130/943] docs: refresh zh-CN model providers --- docs/zh-CN/concepts/model-providers.md | 433 +++++++++++++++++++------ 1 file changed, 334 insertions(+), 99 deletions(-) diff --git a/docs/zh-CN/concepts/model-providers.md b/docs/zh-CN/concepts/model-providers.md index ba345d18743..716e007a3ba 100644 --- a/docs/zh-CN/concepts/model-providers.md +++ b/docs/zh-CN/concepts/model-providers.md @@ -1,12 +1,12 @@ --- read_when: - - 你需要按提供商分类的模型设置参考 + - 你需要一份逐提供商的模型设置参考 - 你需要模型提供商的示例配置或 CLI 新手引导命令 -summary: 模型提供商概述,包含示例配置和 CLI 流程 +summary: 模型提供商概览,包含示例配置和 CLI 流程 title: 模型提供商 x-i18n: - generated_at: "2026-03-16T01:39:16Z" - model: claude-opus-4-5 + generated_at: "2026-03-16T02:12:40Z" + model: claude-opus-4-6 provider: pi source_hash: 978798c80c5809c162f9807072ab48fdf99bfe0db39b2b3c245ce8b4e5451603 source_path: concepts/model-providers.md @@ -15,137 +15,251 @@ x-i18n: # 模型提供商 -本页介绍 **LLM/模型提供商**(不是 WhatsApp/Telegram 等聊天渠道)。 -关于模型选择规则,请参阅 [/concepts/models](/concepts/models)。 +本页涵盖 **LLM/模型提供商** (不是 WhatsApp/Telegram 等聊天渠道)。 +有关模型选择规则,请参阅 [/concepts/models](/concepts/models)。 ## 快速规则 -- 模型引用使用 `provider/model` 格式(例如:`opencode/claude-opus-4-5`)。 -- 如果设置了 `agents.defaults.models`,它将成为允许列表。 -- CLI 辅助工具:`openclaw onboard`、`openclaw models list`、`openclaw models set `。 +- 模型引用使用 `provider/model` (例如: `opencode/claude-opus-4-6`)。 +- 如果你设置了 `agents.defaults.models`,它将成为允许列表。 +- CLI 辅助命令: `openclaw onboard`, `openclaw models list`, `openclaw models set `。 +- 提供商插件可以通过以下方式注入模型目录 `registerProvider({ catalog })`; + OpenClaw 将该输出合并到 `models.providers` 之后再写入 + `models.json`。 +- 提供商插件还可以通过以下方式控制提供商的运行时行为 + `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, + `capabilities`, `prepareExtraParams`, `wrapStreamFn`, + `isCacheTtlEligible`, `prepareRuntimeAuth`, `resolveUsageAuth`,以及 + `fetchUsageSnapshot`。 + +## 插件管理的提供商行为 + +提供商插件现在可以管理大部分提供商特定逻辑,而 OpenClaw 负责维护通用推理循环。 + +典型分工: + +- `catalog`:提供商出现在 `models.providers` +- `resolveDynamicModel`:提供商接受尚未出现在本地静态目录中的模型 ID +- `prepareDynamicModel`:提供商在重试动态解析之前需要刷新元数据 +- `normalizeResolvedModel`:提供商需要传输层或基础 URL 重写 +- `capabilities`:提供商发布会话记录/工具/提供商系列的特殊行为 +- `prepareExtraParams`:提供商默认或规范化每个模型的请求参数 +- `wrapStreamFn`:提供商应用请求头/请求体/模型兼容性封装 +- `isCacheTtlEligible`:提供商决定哪些上游模型 ID 支持 prompt-cache TTL +- `prepareRuntimeAuth`:提供商将配置的凭证转换为短期运行时令牌 +- `resolveUsageAuth`:提供商为以下用途解析使用量/配额凭证 `/usage` + 以及相关的状态/报告界面 +- `fetchUsageSnapshot`:提供商负责使用量端点的获取/解析,而核心仍负责摘要外壳和格式化 + +当前内置示例: + +- `anthropic`:Claude 4.6 向前兼容回退、使用量端点获取,以及 cache-TTL/提供商系列元数据 +- `openrouter`:直通模型 ID、请求封装、提供商能力提示,以及 cache-TTL 策略 +- `github-copilot`:向前兼容模型回退、Claude-thinking 会话记录提示、运行时令牌交换,以及使用量端点获取 +- `openai`:GPT-5.4 向前兼容回退、直接 OpenAI 传输规范化,以及提供商系列元数据 +- `openai-codex`:向前兼容模型回退、传输规范化,以及默认传输参数和使用量端点获取 +- `google-gemini-cli`:Gemini 3.1 向前兼容回退,以及使用量界面的 usage-token 解析和配额端点获取 +- `moonshot`:共享传输、插件管理的 thinking 负载规范化 +- `kilocode`:共享传输、插件管理的请求头、推理负载规范化、Gemini 会话记录提示,以及 cache-TTL 策略 +- `zai`:GLM-5 向前兼容回退, `tool_stream` 默认值、cache-TTL 策略,以及使用量认证和配额获取 +- `mistral`, `opencode`,以及`opencode-go`:插件管理的能力元数据 +- `byteplus`, `cloudflare-ai-gateway`, `huggingface`, `kimi-coding`, + `minimax-portal`, `modelstudio`, `nvidia`, `qianfan`, `qwen-portal`, + `synthetic`, `together`, `venice`, `vercel-ai-gateway`,以及`volcengine`:仅限插件管理的目录 +- `minimax` 和 `xiaomi`:插件管理的目录以及使用量认证/快照逻辑 + +以上涵盖了仍然适用于 OpenClaw 常规传输层的提供商。如果某个提供商需要完全自定义的请求执行器,则属于一个独立的、更深层的扩展层面。 + +## API 密钥轮换 + +- 支持对选定提供商的通用提供商轮换。 +- 通过以下方式配置多个密钥: + - `OPENCLAW_LIVE__KEY` (单个实时覆盖,最高优先级) + - `_API_KEYS` (逗号或分号分隔的列表) + - `_API_KEY` (主密钥) + - `_API_KEY_*` (编号列表,例如 `_API_KEY_1`) +- 对于 Google 提供商, `GOOGLE_API_KEY` 也作为备选项包含在内。 +- 密钥选择顺序按优先级排列并去除重复值。 +- 仅在速率限制响应时使用下一个密钥重试请求(例如 `429`, `rate_limit`, `quota`, `resource exhausted`)。 +- 非速率限制的失败会立即报错;不会尝试密钥轮换。 +- 当所有候选密钥均失败时,返回最后一次尝试的错误。 ## 内置提供商(pi-ai 目录) -OpenClaw 附带 pi-ai 目录。这些提供商**不需要** `models.providers` 配置;只需设置认证 + 选择模型。 +OpenClaw 附带 pi-ai 目录。这些提供商需要 **无需** +`models.providers` 配置;只需设置认证并选择一个模型。 ### OpenAI -- 提供商:`openai` -- 认证:`OPENAI_API_KEY` -- 示例模型:`openai/gpt-5.2` -- CLI:`openclaw onboard --auth-choice openai-api-key` +- 提供商: `openai` +- 认证: `OPENAI_API_KEY` +- 可选轮换: `OPENAI_API_KEYS`, `OPENAI_API_KEY_1`, `OPENAI_API_KEY_2`,加上 `OPENCLAW_LIVE_OPENAI_KEY` (单个覆盖) +- 示例模型: `openai/gpt-5.4`, `openai/gpt-5.4-pro` +- CLI: `openclaw onboard --auth-choice openai-api-key` +- 默认传输为 `auto` (WebSocket 优先,SSE 备选) +- 通过以下方式覆盖每个模型 `agents.defaults.models["openai/"].params.transport` (`"sse"`, `"websocket"`,或 `"auto"`) +- OpenAI Responses WebSocket 预热默认通过以下方式启用 `params.openaiWsWarmup` (`true`/`false`) +- OpenAI 优先处理可以通过以下方式启用 `agents.defaults.models["openai/"].params.serviceTier` +- OpenAI 快速模式可以通过以下方式为每个模型启用 `agents.defaults.models["/"].params.fastMode` +- `openai/gpt-5.3-codex-spark` 在 OpenClaw 中被有意屏蔽,因为 OpenAI 实时 API 会拒绝它;Spark 被视为仅限 Codex 使用 ```json5 { - agents: { defaults: { model: { primary: "openai/gpt-5.2" } } }, + agents: { defaults: { model: { primary: "openai/gpt-5.4" } } }, } ``` ### Anthropic -- 提供商:`anthropic` -- 认证:`ANTHROPIC_API_KEY` 或 `claude setup-token` -- 示例模型:`anthropic/claude-opus-4-5` -- CLI:`openclaw onboard --auth-choice token`(粘贴 setup-token)或 `openclaw models auth paste-token --provider anthropic` +- 提供商: `anthropic` +- 认证: `ANTHROPIC_API_KEY` 或 `claude setup-token` +- 可选轮换: `ANTHROPIC_API_KEYS`, `ANTHROPIC_API_KEY_1`, `ANTHROPIC_API_KEY_2`,加上 `OPENCLAW_LIVE_ANTHROPIC_KEY` (单个覆盖) +- 示例模型: `anthropic/claude-opus-4-6` +- CLI: `openclaw onboard --auth-choice token` (粘贴 setup-token)或 `openclaw models auth paste-token --provider anthropic` +- 直接 API 密钥模型支持共享的 `/fast` 切换和 `params.fastMode`;OpenClaw 将其映射到 Anthropic 的 `service_tier` (`auto` 与 `standard_only`) +- 策略说明:setup-token 支持属于技术兼容性;Anthropic 过去曾阻止部分订阅在 Claude Code 之外的使用。请核实当前 Anthropic 条款,并根据你的风险承受能力做出决定。 +- 建议:Anthropic API 密钥认证是比订阅 setup-token 认证更安全的推荐方式。 ```json5 { - agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } }, + agents: { defaults: { model: { primary: "anthropic/claude-opus-4-6" } } }, } ``` ### OpenAI Code (Codex) -- 提供商:`openai-codex` +- 提供商: `openai-codex` - 认证:OAuth (ChatGPT) -- 示例模型:`openai-codex/gpt-5.2` -- CLI:`openclaw onboard --auth-choice openai-codex` 或 `openclaw models auth login --provider openai-codex` +- 示例模型: `openai-codex/gpt-5.4` +- CLI: `openclaw onboard --auth-choice openai-codex` 或 `openclaw models auth login --provider openai-codex` +- 默认传输为 `auto` (WebSocket 优先,SSE 备选) +- 通过以下方式覆盖每个模型 `agents.defaults.models["openai-codex/"].params.transport` (`"sse"`, `"websocket"`,或 `"auto"`) +- 与相同的 `/fast` 切换和 `params.fastMode` 配置共享,如同直接的 `openai/*` +- `openai-codex/gpt-5.3-codex-spark` 当 Codex OAuth 目录公开时仍然可用;取决于授权资格 +- 策略说明:OpenAI Codex OAuth 明确支持 OpenClaw 等外部工具/工作流。 ```json5 { - agents: { defaults: { model: { primary: "openai-codex/gpt-5.2" } } }, + agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } }, } ``` -### OpenCode Zen +### OpenCode -- 提供商:`opencode` -- 认证:`OPENCODE_API_KEY`(或 `OPENCODE_ZEN_API_KEY`) -- 示例模型:`opencode/claude-opus-4-5` -- CLI:`openclaw onboard --auth-choice opencode-zen` +- 认证: `OPENCODE_API_KEY` (或 `OPENCODE_ZEN_API_KEY`) +- Zen 运行时提供商: `opencode` +- Go 运行时提供商: `opencode-go` +- 示例模型: `opencode/claude-opus-4-6`, `opencode-go/kimi-k2.5` +- CLI: `openclaw onboard --auth-choice opencode-zen` 或 `openclaw onboard --auth-choice opencode-go` ```json5 { - agents: { defaults: { model: { primary: "opencode/claude-opus-4-5" } } }, + agents: { defaults: { model: { primary: "opencode/claude-opus-4-6" } } }, } ``` ### Google Gemini(API 密钥) -- 提供商:`google` -- 认证:`GEMINI_API_KEY` -- 示例模型:`google/gemini-3-pro-preview` -- CLI:`openclaw onboard --auth-choice gemini-api-key` +- 提供商: `google` +- 认证: `GEMINI_API_KEY` +- 可选轮换: `GEMINI_API_KEYS`, `GEMINI_API_KEY_1`, `GEMINI_API_KEY_2`, `GOOGLE_API_KEY` 备选,以及 `OPENCLAW_LIVE_GEMINI_KEY` (单个覆盖) +- 示例模型: `google/gemini-3.1-pro-preview`, `google/gemini-3-flash-preview` +- 兼容性:使用旧版 OpenClaw 配置的 `google/gemini-3.1-flash-preview` 会被规范化为 `google/gemini-3-flash-preview` +- CLI: `openclaw onboard --auth-choice gemini-api-key` ### Google Vertex 和 Gemini CLI -- 提供商:`google-vertex`、`google-gemini-cli` +- 提供商: `google-vertex`, `google-gemini-cli` - 认证:Vertex 使用 gcloud ADC;Gemini CLI 使用其 OAuth 流程 -- 注意:OpenClaw 中的 Gemini CLI OAuth 属于非官方集成。一些用户报告称,在第三方客户端中使用后其 Google 账号受到了限制。继续前请先查看 Google 条款,并尽量使用非关键账号。 -- Gemini CLI OAuth 作为捆绑 `google` 插件的一部分提供。 - - 启用:`openclaw plugins enable google` - - 登录:`openclaw models auth login --provider google-gemini-cli --set-default` - - 注意:你**不需要**将客户端 ID 或密钥粘贴到 `openclaw.json` 中。CLI 登录流程将令牌存储在 Gateway 网关主机的认证配置文件中。 +- 注意:OpenClaw 中的 Gemini CLI OAuth 是非官方集成。部分用户报告称在使用第三方客户端后 Google 账户受到限制。请查阅 Google 条款,如果你选择继续,建议使用非关键账户。 +- Gemini CLI OAuth 作为内置的 `google` 插件的一部分提供。 + - 启用: `openclaw plugins enable google` + - 登录: `openclaw models auth login --provider google-gemini-cli --set-default` + - 注意:你确实 **不** 需要将 client ID 或 secret 粘贴到 `openclaw.json`中。CLI 登录流程将令牌存储在 Gateway 网关主机的认证配置文件中。 ### Z.AI (GLM) -- 提供商:`zai` -- 认证:`ZAI_API_KEY` -- 示例模型:`zai/glm-4.7` -- CLI:`openclaw onboard --auth-choice zai-api-key` - - 别名:`z.ai/*` 和 `z-ai/*` 规范化为 `zai/*` +- 提供商: `zai` +- 认证: `ZAI_API_KEY` +- 示例模型: `zai/glm-5` +- CLI: `openclaw onboard --auth-choice zai-api-key` + - 别名: `z.ai/*` 和 `z-ai/*` 规范化为 `zai/*` ### Vercel AI Gateway -- 提供商:`vercel-ai-gateway` -- 认证:`AI_GATEWAY_API_KEY` -- 示例模型:`vercel-ai-gateway/anthropic/claude-opus-4.5` -- CLI:`openclaw onboard --auth-choice ai-gateway-api-key` +- 提供商: `vercel-ai-gateway` +- 认证: `AI_GATEWAY_API_KEY` +- 示例模型: `vercel-ai-gateway/anthropic/claude-opus-4.6` +- CLI: `openclaw onboard --auth-choice ai-gateway-api-key` -### 其他内置提供商 +### Kilo Gateway -- OpenRouter:`openrouter`(`OPENROUTER_API_KEY`) -- 示例模型:`openrouter/anthropic/claude-sonnet-4-5` -- xAI:`xai`(`XAI_API_KEY`) -- Groq:`groq`(`GROQ_API_KEY`) -- Cerebras:`cerebras`(`CEREBRAS_API_KEY`) +- 提供商: `kilocode` +- 认证: `KILOCODE_API_KEY` +- 示例模型: `kilocode/anthropic/claude-opus-4.6` +- CLI: `openclaw onboard --kilocode-api-key ` +- 基础 URL: `https://api.kilo.ai/api/gateway/` +- 扩展的内置目录包括 GLM-5 Free、MiniMax M2.5 Free、GPT-5.2、Gemini 3 Pro Preview、Gemini 3 Flash Preview、Grok Code Fast 1 和 Kimi K2.5。 + +参阅 [/providers/kilocode](/providers/kilocode) 了解详情。 + +### 其他内置提供商插件 + +- OpenRouter: `openrouter` (`OPENROUTER_API_KEY`) +- 示例模型: `openrouter/anthropic/claude-sonnet-4-5` +- Kilo Gateway: `kilocode` (`KILOCODE_API_KEY`) +- 示例模型: `kilocode/anthropic/claude-opus-4.6` +- MiniMax: `minimax` (`MINIMAX_API_KEY`) +- Moonshot: `moonshot` (`MOONSHOT_API_KEY`) +- Kimi Coding: `kimi-coding` (`KIMI_API_KEY` 或 `KIMICODE_API_KEY`) +- Qianfan: `qianfan` (`QIANFAN_API_KEY`) +- Model Studio: `modelstudio` (`MODELSTUDIO_API_KEY`) +- NVIDIA: `nvidia` (`NVIDIA_API_KEY`) +- Together: `together` (`TOGETHER_API_KEY`) +- Venice: `venice` (`VENICE_API_KEY`) +- Xiaomi: `xiaomi` (`XIAOMI_API_KEY`) +- Vercel AI Gateway: `vercel-ai-gateway` (`AI_GATEWAY_API_KEY`) +- Hugging Face Inference: `huggingface` (`HUGGINGFACE_HUB_TOKEN` 或 `HF_TOKEN`) +- Cloudflare AI Gateway: `cloudflare-ai-gateway` (`CLOUDFLARE_AI_GATEWAY_API_KEY`) +- Volcengine: `volcengine` (`VOLCANO_ENGINE_API_KEY`) +- BytePlus: `byteplus` (`BYTEPLUS_API_KEY`) +- xAI: `xai` (`XAI_API_KEY`) +- Mistral: `mistral` (`MISTRAL_API_KEY`) +- 示例模型: `mistral/mistral-large-latest` +- CLI: `openclaw onboard --auth-choice mistral-api-key` +- Groq: `groq` (`GROQ_API_KEY`) +- Cerebras: `cerebras` (`CEREBRAS_API_KEY`) - Cerebras 上的 GLM 模型使用 ID `zai-glm-4.7` 和 `zai-glm-4.6`。 - - OpenAI 兼容的基础 URL:`https://api.cerebras.ai/v1`。 -- Mistral:`mistral`(`MISTRAL_API_KEY`) -- GitHub Copilot:`github-copilot`(`COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN`) + - 兼容 OpenAI 的基础 URL: `https://api.cerebras.ai/v1`。 +- GitHub Copilot: `github-copilot` (`COPILOT_GITHUB_TOKEN`/`GH_TOKEN`/`GITHUB_TOKEN`) +- Hugging Face Inference 示例模型: `huggingface/deepseek-ai/DeepSeek-R1`;CLI: `openclaw onboard --auth-choice huggingface-api-key`。参阅 [Hugging Face (Inference)](/providers/huggingface)。 -## 通过 `models.providers` 配置的提供商(自定义/基础 URL) +## 通过以下方式提供的提供商 `models.providers` (自定义/基础 URL) -使用 `models.providers`(或 `models.json`)添加**自定义**提供商或 OpenAI/Anthropic 兼容的代理。 +使用 `models.providers` (或 `models.json`)来添加 **自定义** 提供商或 OpenAI/Anthropic 兼容代理。 + +下方许多内置提供商插件已经发布了默认目录。 +使用显式的 `models.providers.` 条目仅在你需要覆盖默认基础 URL、请求头或模型列表时使用。 ### Moonshot AI (Kimi) -Moonshot 使用 OpenAI 兼容端点,因此将其配置为自定义提供商: +Moonshot 使用兼容 OpenAI 的端点,因此将其配置为自定义提供商: -- 提供商:`moonshot` -- 认证:`MOONSHOT_API_KEY` -- 示例模型:`moonshot/kimi-k2.5` +- 提供商: `moonshot` +- 认证: `MOONSHOT_API_KEY` +- 示例模型: `moonshot/kimi-k2.5` Kimi K2 模型 ID: -{/_ moonshot-kimi-k2-model-refs:start _/ && null} +[//]: # "moonshot-kimi-k2-model-refs:start" - `moonshot/kimi-k2.5` - `moonshot/kimi-k2-0905-preview` - `moonshot/kimi-k2-turbo-preview` - `moonshot/kimi-k2-thinking` - `moonshot/kimi-k2-thinking-turbo` - {/_ moonshot-kimi-k2-model-refs:end _/ && null} + +[//]: # "moonshot-kimi-k2-model-refs:end" ```json5 { @@ -170,9 +284,9 @@ Kimi K2 模型 ID: Kimi Coding 使用 Moonshot AI 的 Anthropic 兼容端点: -- 提供商:`kimi-coding` -- 认证:`KIMI_API_KEY` -- 示例模型:`kimi-coding/k2p5` +- 提供商: `kimi-coding` +- 认证: `KIMI_API_KEY` +- 示例模型: `kimi-coding/k2p5` ```json5 { @@ -183,13 +297,12 @@ Kimi Coding 使用 Moonshot AI 的 Anthropic 兼容端点: } ``` -### Qwen OAuth(免费层级) +### Qwen OAuth(免费套餐) Qwen 通过设备码流程提供对 Qwen Coder + Vision 的 OAuth 访问。 -启用捆绑插件,然后登录: +内置提供商插件默认启用,只需登录: ```bash -openclaw plugins enable qwen-portal-auth openclaw models auth login --provider qwen-portal --set-default ``` @@ -198,21 +311,85 @@ openclaw models auth login --provider qwen-portal --set-default - `qwen-portal/coder-model` - `qwen-portal/vision-model` -参见 [/providers/qwen](/providers/qwen) 了解设置详情和注意事项。 +参阅 [/providers/qwen](/providers/qwen) 了解详情和注意事项。 -### Synthetic +### 火山引擎(豆包) -Synthetic 通过 `synthetic` 提供商提供 Anthropic 兼容模型: +火山引擎提供对豆包及中国其他模型的访问。 -- 提供商:`synthetic` -- 认证:`SYNTHETIC_API_KEY` -- 示例模型:`synthetic/hf:MiniMaxAI/MiniMax-M2.1` -- CLI:`openclaw onboard --auth-choice synthetic-api-key` +- 提供商: `volcengine` (编码: `volcengine-plan`) +- 认证: `VOLCANO_ENGINE_API_KEY` +- 示例模型: `volcengine/doubao-seed-1-8-251228` +- CLI: `openclaw onboard --auth-choice volcengine-api-key` ```json5 { agents: { - defaults: { model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" } }, + defaults: { model: { primary: "volcengine/doubao-seed-1-8-251228" } }, + }, +} +``` + +可用模型: + +- `volcengine/doubao-seed-1-8-251228` (豆包 Seed 1.8) +- `volcengine/doubao-seed-code-preview-251028` +- `volcengine/kimi-k2-5-260127` (Kimi K2.5) +- `volcengine/glm-4-7-251222` (GLM 4.7) +- `volcengine/deepseek-v3-2-251201` (DeepSeek V3.2 128K) + +编码模型(`volcengine-plan`): + +- `volcengine-plan/ark-code-latest` +- `volcengine-plan/doubao-seed-code` +- `volcengine-plan/kimi-k2.5` +- `volcengine-plan/kimi-k2-thinking` +- `volcengine-plan/glm-4.7` + +### BytePlus(国际版) + +BytePlus ARK 为国际用户提供与火山引擎相同的模型访问。 + +- 提供商: `byteplus` (编码: `byteplus-plan`) +- 认证: `BYTEPLUS_API_KEY` +- 示例模型: `byteplus/seed-1-8-251228` +- CLI: `openclaw onboard --auth-choice byteplus-api-key` + +```json5 +{ + agents: { + defaults: { model: { primary: "byteplus/seed-1-8-251228" } }, + }, +} +``` + +可用模型: + +- `byteplus/seed-1-8-251228` (Seed 1.8) +- `byteplus/kimi-k2-5-260127` (Kimi K2.5) +- `byteplus/glm-4-7-251222` (GLM 4.7) + +编码模型(`byteplus-plan`): + +- `byteplus-plan/ark-code-latest` +- `byteplus-plan/doubao-seed-code` +- `byteplus-plan/kimi-k2.5` +- `byteplus-plan/kimi-k2-thinking` +- `byteplus-plan/glm-4.7` + +### Synthetic + +Synthetic 提供 Anthropic 兼容模型,位于 `synthetic` 提供商背后: + +- 提供商: `synthetic` +- 认证: `SYNTHETIC_API_KEY` +- 示例模型: `synthetic/hf:MiniMaxAI/MiniMax-M2.5` +- CLI: `openclaw onboard --auth-choice synthetic-api-key` + +```json5 +{ + agents: { + defaults: { model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.5" } }, }, models: { mode: "merge", @@ -221,7 +398,7 @@ Synthetic 通过 `synthetic` 提供商提供 Anthropic 兼容模型: baseUrl: "https://api.synthetic.new/anthropic", apiKey: "${SYNTHETIC_API_KEY}", api: "anthropic-messages", - models: [{ id: "hf:MiniMaxAI/MiniMax-M2.1", name: "MiniMax M2.1" }], + models: [{ id: "hf:MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5" }], }, }, }, @@ -230,21 +407,21 @@ Synthetic 通过 `synthetic` 提供商提供 Anthropic 兼容模型: ### MiniMax -MiniMax 通过 `models.providers` 配置,因为它使用自定义端点: +MiniMax 通过以下方式配置 `models.providers` ,因为它使用自定义端点: -- MiniMax(Anthropic 兼容):`--auth-choice minimax-api` -- 认证:`MINIMAX_API_KEY` +- MiniMax(Anthropic 兼容): `--auth-choice minimax-api` +- 认证: `MINIMAX_API_KEY` -参见 [/providers/minimax](/providers/minimax) 了解设置详情、模型选项和配置片段。 +参阅 [/providers/minimax](/providers/minimax) 了解详情、模型选项和配置代码片段。 ### Ollama -Ollama 是提供 OpenAI 兼容 API 的本地 LLM 运行时: +Ollama 作为内置提供商插件提供,并使用 Ollama 的原生 API: -- 提供商:`ollama` +- 提供商: `ollama` - 认证:无需(本地服务器) -- 示例模型:`ollama/llama3.3` -- 安装:https://ollama.ai +- 示例模型: `ollama/llama3.3` +- 安装: [https://ollama.com/download](https://ollama.com/download) ```bash # Install Ollama, then pull a model: @@ -259,18 +436,73 @@ ollama pull llama3.3 } ``` -当 Ollama 在本地 `http://127.0.0.1:11434/v1` 运行时会自动检测。参见 [/providers/ollama](/providers/ollama) 了解模型推荐和自定义配置。 +Ollama 在本地通过以下地址检测 `http://127.0.0.1:11434` 当你通过以下方式选择启用时 +`OLLAMA_API_KEY`,内置提供商插件会将 Ollama 直接添加到 +`openclaw onboard` 和模型选择器中。参阅 [/providers/ollama](/providers/ollama) +了解新手引导、云端/本地模式和自定义配置。 + +### vLLM + +vLLM 作为内置提供商插件提供,用于本地/自托管的兼容 OpenAI 服务器: + +- 提供商: `vllm` +- 认证:可选(取决于你的服务器) +- 默认基础 URL: `http://127.0.0.1:8000/v1` + +要在本地选择启用自动发现(如果你的服务器不强制认证,任何值均可): + +```bash +export VLLM_API_KEY="vllm-local" +``` + +然后设置一个模型(替换为由 `/v1/models`): + +```json5 +{ + agents: { + defaults: { model: { primary: "vllm/your-model-id" } }, + }, +} +``` + +参阅 [/providers/vllm](/providers/vllm) 了解详情。 + +### SGLang + +SGLang 作为内置提供商插件提供,用于快速自托管的兼容 OpenAI 服务器: + +- 提供商: `sglang` +- 认证:可选(取决于你的服务器) +- 默认基础 URL: `http://127.0.0.1:30000/v1` + +要在本地选择启用自动发现(如果你的服务器不强制认证,任何值均可): + +```bash +export SGLANG_API_KEY="sglang-local" +``` + +然后设置一个模型(替换为由 `/v1/models`): + +```json5 +{ + agents: { + defaults: { model: { primary: "sglang/your-model-id" } }, + }, +} +``` + +参阅 [/providers/sglang](/providers/sglang) 了解详情。 ### 本地代理(LM Studio、vLLM、LiteLLM 等) -示例(OpenAI 兼容): +示例(兼容 OpenAI): ```json5 { agents: { defaults: { - model: { primary: "lmstudio/minimax-m2.1-gs32" }, - models: { "lmstudio/minimax-m2.1-gs32": { alias: "Minimax" } }, + model: { primary: "lmstudio/minimax-m2.5-gs32" }, + models: { "lmstudio/minimax-m2.5-gs32": { alias: "Minimax" } }, }, }, models: { @@ -281,8 +513,8 @@ ollama pull llama3.3 api: "openai-completions", models: [ { - id: "minimax-m2.1-gs32", - name: "MiniMax M2.1", + id: "minimax-m2.5-gs32", + name: "MiniMax M2.5", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -298,21 +530,24 @@ ollama pull llama3.3 注意事项: -- 对于自定义提供商,`reasoning`、`input`、`cost`、`contextWindow` 和 `maxTokens` 是可选的。 +- 对于自定义提供商, `reasoning`, `input`, `cost`, `contextWindow`,以及`maxTokens` 是可选的。 省略时,OpenClaw 默认为: - `reasoning: false` - `input: ["text"]` - `cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }` - `contextWindow: 200000` - `maxTokens: 8192` -- 建议:设置与你的代理/模型限制匹配的显式值。 +- 建议:设置与你的代理/模型限制相匹配的显式值。 +- 对于 `api: "openai-completions"` 在非原生端点上(任何非空的 `baseUrl` 且主机不是 `api.openai.com`),OpenClaw 强制使用 `compat.supportsDeveloperRole: false` 以避免提供商对不支持的 `developer` 角色返回 400 错误。 +- 如果 `baseUrl` 为空/省略,OpenClaw 保持默认的 OpenAI 行为(解析为 `api.openai.com`)。 +- 为安全起见,显式的 `compat.supportsDeveloperRole: true` 在非原生 `openai-completions` 端点上仍会被覆盖。 ## CLI 示例 ```bash openclaw onboard --auth-choice opencode-zen -openclaw models set opencode/claude-opus-4-5 +openclaw models set opencode/claude-opus-4-6 openclaw models list ``` -另请参阅:[/gateway/configuration](/gateway/configuration) 了解完整配置示例。 +另请参阅: [/gateway/configuration](/gateway/configuration) 查看完整配置示例。 From acae0b60c2b1457bbba58a65da533915328d325c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 19:27:45 -0700 Subject: [PATCH 131/943] perf(plugins): lazy-load channel setup entrypoints --- docs/tools/plugin.md | 11 +-- extensions/discord/package.json | 3 +- extensions/discord/setup-entry.ts | 3 + extensions/imessage/package.json | 3 +- extensions/imessage/setup-entry.ts | 3 + extensions/signal/package.json | 3 +- extensions/signal/setup-entry.ts | 3 + extensions/slack/package.json | 3 +- extensions/slack/setup-entry.ts | 3 + extensions/telegram/package.json | 3 +- extensions/telegram/setup-entry.ts | 3 + extensions/whatsapp/package.json | 3 +- extensions/whatsapp/setup-entry.ts | 3 + src/commands/onboard-channels.ts | 59 +++++++++------- src/commands/onboarding/registry.ts | 74 ++++++++------------ src/plugins/loader.test.ts | 101 ++++++++++++++++++++++++++++ src/plugins/loader.ts | 33 ++++++++- src/plugins/registry.ts | 2 +- src/plugins/types.ts | 2 +- 19 files changed, 230 insertions(+), 88 deletions(-) create mode 100644 extensions/discord/setup-entry.ts create mode 100644 extensions/imessage/setup-entry.ts create mode 100644 extensions/signal/setup-entry.ts create mode 100644 extensions/slack/setup-entry.ts create mode 100644 extensions/telegram/setup-entry.ts create mode 100644 extensions/whatsapp/setup-entry.ts diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 91613cbe731..3987ff6a7eb 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -769,10 +769,11 @@ Security note: `openclaw plugins install` installs plugin dependencies with trees "pure JS/TS" and avoid packages that require `postinstall` builds. Optional: `openclaw.setupEntry` can point at a lightweight setup-only module. -When OpenClaw needs onboarding/setup surfaces for a disabled channel plugin, it -loads `setupEntry` instead of the full plugin entry. This keeps startup and -onboarding lighter when your main plugin entry also wires tools, hooks, or -other runtime-only code. +When OpenClaw needs onboarding/setup surfaces for a disabled channel plugin, or +when a channel plugin is enabled but still unconfigured, it loads `setupEntry` +instead of the full plugin entry. This keeps startup and onboarding lighter +when your main plugin entry also wires tools, hooks, or other runtime-only +code. ### Channel catalog metadata @@ -1663,7 +1664,7 @@ Recommended packaging: Publishing contract: - Plugin `package.json` must include `openclaw.extensions` with one or more entry files. -- Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled channel onboarding/setup. +- Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled or still-unconfigured channel onboarding/setup. - Entry files can be `.js` or `.ts` (jiti loads TS at runtime). - `openclaw plugins install ` uses `npm pack`, extracts into `~/.openclaw/extensions//`, and enables it in config. - Config key stability: scoped packages are normalized to the **unscoped** id for `plugins.entries.*`. diff --git a/extensions/discord/package.json b/extensions/discord/package.json index a85eb37b85f..43e00315f28 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -6,6 +6,7 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "setupEntry": "./setup-entry.ts" } } diff --git a/extensions/discord/setup-entry.ts b/extensions/discord/setup-entry.ts new file mode 100644 index 00000000000..56673347d64 --- /dev/null +++ b/extensions/discord/setup-entry.ts @@ -0,0 +1,3 @@ +import { discordPlugin } from "./src/channel.js"; + +export default { plugin: discordPlugin }; diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index c0988ee601c..591deea559b 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -7,6 +7,7 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "setupEntry": "./setup-entry.ts" } } diff --git a/extensions/imessage/setup-entry.ts b/extensions/imessage/setup-entry.ts new file mode 100644 index 00000000000..4b0cc6203e2 --- /dev/null +++ b/extensions/imessage/setup-entry.ts @@ -0,0 +1,3 @@ +import { imessagePlugin } from "./src/channel.js"; + +export default { plugin: imessagePlugin }; diff --git a/extensions/signal/package.json b/extensions/signal/package.json index 67d6eae6506..f63128914c9 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -7,6 +7,7 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "setupEntry": "./setup-entry.ts" } } diff --git a/extensions/signal/setup-entry.ts b/extensions/signal/setup-entry.ts new file mode 100644 index 00000000000..afe80451845 --- /dev/null +++ b/extensions/signal/setup-entry.ts @@ -0,0 +1,3 @@ +import { signalPlugin } from "./src/channel.js"; + +export default { plugin: signalPlugin }; diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 183cdce7ad4..51439a37170 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -7,6 +7,7 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "setupEntry": "./setup-entry.ts" } } diff --git a/extensions/slack/setup-entry.ts b/extensions/slack/setup-entry.ts new file mode 100644 index 00000000000..d219e597148 --- /dev/null +++ b/extensions/slack/setup-entry.ts @@ -0,0 +1,3 @@ +import { slackPlugin } from "./src/channel.js"; + +export default { plugin: slackPlugin }; diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index 92054ca01a3..deed30477a9 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -7,6 +7,7 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "setupEntry": "./setup-entry.ts" } } diff --git a/extensions/telegram/setup-entry.ts b/extensions/telegram/setup-entry.ts new file mode 100644 index 00000000000..b5e7fc8c073 --- /dev/null +++ b/extensions/telegram/setup-entry.ts @@ -0,0 +1,3 @@ +import { telegramPlugin } from "./src/channel.js"; + +export default { plugin: telegramPlugin }; diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index ec73a1b0613..356b2e3894b 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -7,6 +7,7 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "setupEntry": "./setup-entry.ts" } } diff --git a/extensions/whatsapp/setup-entry.ts b/extensions/whatsapp/setup-entry.ts new file mode 100644 index 00000000000..0dd48c5b785 --- /dev/null +++ b/extensions/whatsapp/setup-entry.ts @@ -0,0 +1,3 @@ +import { whatsappPlugin } from "./src/channel.js"; + +export default { plugin: whatsappPlugin }; diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index cdb987914bc..cd269ac2cf9 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -5,7 +5,6 @@ import { getChannelSetupPlugin, listChannelSetupPlugins, } from "../channels/plugins/setup-registry.js"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../channels/plugins/setup-wizard.js"; import type { ChannelMeta, ChannelPlugin } from "../channels/plugins/types.js"; import { formatChannelPrimerLine, @@ -28,8 +27,8 @@ import { loadOnboardingPluginRegistrySnapshotForChannel, } from "./onboarding/plugin-install.js"; import { - getChannelOnboardingAdapter, - listChannelOnboardingAdapters, + loadBundledChannelOnboardingPlugin, + resolveChannelOnboardingAdapterForPlugin, } from "./onboarding/registry.js"; import type { ChannelOnboardingAdapter, @@ -121,7 +120,8 @@ async function collectChannelStatus(params: { cfg: OpenClawConfig; options?: SetupChannelsOptions; accountOverrides: Partial>; - installedPlugins?: ReturnType; + installedPlugins?: ChannelPlugin[]; + resolveAdapter?: (channel: ChannelChoice) => ChannelOnboardingAdapter | undefined; }): Promise { const installedPlugins = params.installedPlugins ?? listChannelSetupPlugins(); const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); @@ -134,14 +134,24 @@ async function collectChannelStatus(params: { }).plugins.flatMap((plugin) => plugin.channels), ); const catalogEntries = allCatalogEntries.filter((entry) => !installedChannelIds.has(entry.id)); + const resolveAdapter = + params.resolveAdapter ?? + ((channel: ChannelChoice) => + resolveChannelOnboardingAdapterForPlugin( + installedPlugins.find((plugin) => plugin.id === channel), + )); const statusEntries = await Promise.all( - listChannelOnboardingAdapters().map((adapter) => - adapter.getStatus({ + installedPlugins.flatMap((plugin) => { + const adapter = resolveAdapter(plugin.id); + if (!adapter) { + return []; + } + return adapter.getStatus({ cfg: params.cfg, options: params.options, accountOverrides: params.accountOverrides, - }), - ), + }); + }), ); const statusByChannel = new Map(statusEntries.map((entry) => [entry.channel, entry])); const fallbackStatuses = listChatChannels() @@ -270,7 +280,7 @@ async function maybeConfigureDmPolicies(params: { resolveAdapter?: (channel: ChannelChoice) => ChannelOnboardingAdapter | undefined; }): Promise { const { selection, prompter, accountIdsByChannel } = params; - const resolve = params.resolveAdapter ?? getChannelOnboardingAdapter; + const resolve = params.resolveAdapter; const dmPolicies = selection .map((channel) => resolve(channel)?.dmPolicy) .filter(Boolean) as ChannelOnboardingDmPolicy[]; @@ -362,10 +372,10 @@ export async function setupChannels( } return Array.from(merged.values()); }; - const loadScopedChannelPlugin = ( + const loadScopedChannelPlugin = async ( channel: ChannelChoice, pluginId?: string, - ): ChannelPlugin | undefined => { + ): Promise => { const existing = getVisibleChannelPlugin(channel); if (existing) { return existing; @@ -382,22 +392,20 @@ export async function setupChannels( snapshot.channelSetups.find((entry) => entry.plugin.id === channel)?.plugin; if (plugin) { rememberScopedPlugin(plugin); + return plugin; } - return plugin; + const bundledPlugin = await loadBundledChannelOnboardingPlugin(channel); + if (bundledPlugin) { + rememberScopedPlugin(bundledPlugin); + } + return bundledPlugin; }; const getVisibleOnboardingAdapter = (channel: ChannelChoice) => { - const adapter = getChannelOnboardingAdapter(channel); - if (adapter) { - return adapter; - } const scopedPlugin = scopedPluginsById.get(channel); - if (!scopedPlugin?.setupWizard) { - return undefined; + if (scopedPlugin) { + return resolveChannelOnboardingAdapterForPlugin(scopedPlugin); } - return buildChannelOnboardingAdapterFromSetupWizard({ - plugin: scopedPlugin, - wizard: scopedPlugin.setupWizard, - }); + return resolveChannelOnboardingAdapterForPlugin(getChannelSetupPlugin(channel)); }; const preloadConfiguredExternalPlugins = () => { // Keep onboarding memory bounded by snapshot-loading only configured external plugins. @@ -412,7 +420,7 @@ export async function setupChannels( if (!explicitlyEnabled && !isChannelConfigured(next, channel)) { continue; } - loadScopedChannelPlugin(channel, entry.pluginId); + void loadScopedChannelPlugin(channel, entry.pluginId); } }; if (options?.whatsappAccountId?.trim()) { @@ -426,6 +434,7 @@ export async function setupChannels( options, accountOverrides, installedPlugins: listVisibleInstalledPlugins(), + resolveAdapter: getVisibleOnboardingAdapter, }); if (!options?.skipStatusNote && statusLines.length > 0) { await prompter.note(statusLines.join("\n"), "Channel status"); @@ -586,8 +595,8 @@ export async function setupChannels( ); return false; } + const plugin = await loadScopedChannelPlugin(channel); const adapter = getVisibleOnboardingAdapter(channel); - const plugin = loadScopedChannelPlugin(channel); if (!plugin) { if (adapter) { await prompter.note( @@ -752,7 +761,7 @@ export async function setupChannels( if (!result.installed) { return; } - loadScopedChannelPlugin(channel, result.pluginId ?? catalogEntry.pluginId); + await loadScopedChannelPlugin(channel, result.pluginId ?? catalogEntry.pluginId); await refreshStatus(channel); } else { const enabled = await enableBundledPluginForSetup(channel); diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts index 99009ee8fac..01bc0deeb7a 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/onboarding/registry.ts @@ -1,54 +1,15 @@ -import { discordPlugin } from "../../../extensions/discord/src/channel.js"; -import { imessagePlugin } from "../../../extensions/imessage/src/channel.js"; -import { signalPlugin } from "../../../extensions/signal/src/channel.js"; -import { slackPlugin } from "../../../extensions/slack/src/channel.js"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; -import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js"; import { listChannelSetupPlugins } from "../../channels/plugins/setup-registry.js"; import { buildChannelOnboardingAdapterFromSetupWizard } from "../../channels/plugins/setup-wizard.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { ChannelChoice } from "../onboard-types.js"; import type { ChannelOnboardingAdapter } from "./types.js"; -const telegramOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ - plugin: telegramPlugin, - wizard: telegramPlugin.setupWizard!, -}); -const discordOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ - plugin: discordPlugin, - wizard: discordPlugin.setupWizard!, -}); -const slackOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ - plugin: slackPlugin, - wizard: slackPlugin.setupWizard!, -}); -const signalOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ - plugin: signalPlugin, - wizard: signalPlugin.setupWizard!, -}); -const imessageOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ - plugin: imessagePlugin, - wizard: imessagePlugin.setupWizard!, -}); -const whatsappOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ - plugin: whatsappPlugin, - wizard: whatsappPlugin.setupWizard!, -}); - -const BUILTIN_ONBOARDING_ADAPTERS: ChannelOnboardingAdapter[] = [ - telegramOnboardingAdapter, - whatsappOnboardingAdapter, - discordOnboardingAdapter, - slackOnboardingAdapter, - signalOnboardingAdapter, - imessageOnboardingAdapter, -]; - const setupWizardAdapters = new WeakMap(); -function resolveChannelOnboardingAdapter( - plugin: ReturnType[number], +export function resolveChannelOnboardingAdapterForPlugin( + plugin?: ChannelPlugin, ): ChannelOnboardingAdapter | undefined { - if (plugin.setupWizard) { + if (plugin?.setupWizard) { const cached = setupWizardAdapters.get(plugin); if (cached) { return cached; @@ -64,11 +25,9 @@ function resolveChannelOnboardingAdapter( } const CHANNEL_ONBOARDING_ADAPTERS = () => { - const adapters = new Map( - BUILTIN_ONBOARDING_ADAPTERS.map((adapter) => [adapter.channel, adapter] as const), - ); + const adapters = new Map(); for (const plugin of listChannelSetupPlugins()) { - const adapter = resolveChannelOnboardingAdapter(plugin); + const adapter = resolveChannelOnboardingAdapterForPlugin(plugin); if (!adapter) { continue; } @@ -87,6 +46,27 @@ export function listChannelOnboardingAdapters(): ChannelOnboardingAdapter[] { return Array.from(CHANNEL_ONBOARDING_ADAPTERS().values()); } +export async function loadBundledChannelOnboardingPlugin( + channel: ChannelChoice, +): Promise { + switch (channel) { + case "discord": + return (await import("../../../extensions/discord/setup-entry.js")).default.plugin; + case "imessage": + return (await import("../../../extensions/imessage/setup-entry.js")).default.plugin; + case "signal": + return (await import("../../../extensions/signal/setup-entry.js")).default.plugin; + case "slack": + return (await import("../../../extensions/slack/setup-entry.js")).default.plugin; + case "telegram": + return (await import("../../../extensions/telegram/setup-entry.js")).default.plugin; + case "whatsapp": + return (await import("../../../extensions/whatsapp/setup-entry.js")).default.plugin; + default: + return undefined; + } +} + // Legacy aliases (pre-rename). export const getProviderOnboardingAdapter = getChannelOnboardingAdapter; export const listProviderOnboardingAdapters = listChannelOnboardingAdapters; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index fb6805667cb..45710ef08bf 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1885,6 +1885,107 @@ module.exports = { expect(setupRegistry.channels).toHaveLength(0); }); + it("uses package setupEntry for enabled but unconfigured channel loads", () => { + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + const fullMarker = path.join(pluginDir, "full-loaded.txt"); + const setupMarker = path.join(pluginDir, "setup-loaded.txt"); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/setup-runtime-test", + openclaw: { + extensions: ["./index.cjs"], + setupEntry: "./setup-entry.cjs", + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "setup-runtime-test", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["setup-runtime-test"], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + id: "setup-runtime-test", + register(api) { + api.registerChannel({ + plugin: { + id: "setup-runtime-test", + meta: { + id: "setup-runtime-test", + label: "Setup Runtime Test", + selectionLabel: "Setup Runtime Test", + docsPath: "/channels/setup-runtime-test", + blurb: "full entry should not run while unconfigured", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, + }); + }, +};`, + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); +module.exports = { + plugin: { + id: "setup-runtime-test", + meta: { + id: "setup-runtime-test", + label: "Setup Runtime Test", + selectionLabel: "Setup Runtime Test", + docsPath: "/channels/setup-runtime-test", + blurb: "setup runtime", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, +};`, + "utf-8", + ); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-runtime-test"], + }, + }, + }); + + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + expect(registry.channelSetups).toHaveLength(1); + expect(registry.channels).toHaveLength(1); + }); + it("blocks before_prompt_build but preserves legacy model overrides when prompt injection is disabled", async () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 40fd3e36cfd..a58d0a640a2 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -5,6 +5,7 @@ import { createJiti } from "jiti"; import type { ChannelDock } from "../channels/dock.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; +import { isChannelConfigured } from "../config/plugin-auto-enable.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; @@ -357,6 +358,20 @@ function resolveSetupChannelRegistration(moduleExport: unknown): { }; } +function shouldLoadChannelPluginInSetupRuntime(params: { + manifestChannels: string[]; + setupSource?: string; + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): boolean { + if (!params.setupSource || params.manifestChannels.length === 0) { + return false; + } + return !params.manifestChannels.some((channelId) => + isChannelConfigured(params.cfg, channelId, params.env), + ); +} + function createPluginRecord(params: { id: string; name?: string; @@ -924,7 +939,15 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi }; const registrationMode = enableState.enabled - ? "full" + ? !validateOnly && + shouldLoadChannelPluginInSetupRuntime({ + manifestChannels: manifestRecord.channels, + setupSource: manifestRecord.setupSource, + cfg, + env, + }) + ? "setup-runtime" + : "full" : includeSetupOnlyChannelPlugins && !validateOnly && manifestRecord.channels.length > 0 ? "setup-only" : null; @@ -994,7 +1017,8 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const pluginRoot = safeRealpathOrResolve(candidate.rootDir); const loadSource = - registrationMode === "setup-only" && manifestRecord.setupSource + (registrationMode === "setup-only" || registrationMode === "setup-runtime") && + manifestRecord.setupSource ? manifestRecord.setupSource : candidate.source; const opened = openBoundaryFileSync({ @@ -1029,7 +1053,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } - if (registrationMode === "setup-only" && manifestRecord.setupSource) { + if ( + (registrationMode === "setup-only" || registrationMode === "setup-runtime") && + manifestRecord.setupSource + ) { const setupRegistration = resolveSetupChannelRegistration(mod); if (setupRegistration.plugin) { if (setupRegistration.plugin.id && setupRegistration.plugin.id !== record.id) { diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 42e9c236909..9b450af26e7 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -481,7 +481,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { return; } const existingRuntime = registry.channels.find((entry) => entry.plugin.id === id); - if (mode === "full" && existingRuntime) { + if (mode !== "setup-only" && existingRuntime) { pushDiagnostic({ level: "error", pluginId: record.id, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 9ad44fff40d..3b133642313 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -956,7 +956,7 @@ export type OpenClawPluginModule = | OpenClawPluginDefinition | ((api: OpenClawPluginApi) => void | Promise); -export type PluginRegistrationMode = "full" | "setup-only"; +export type PluginRegistrationMode = "full" | "setup-only" | "setup-runtime"; export type OpenClawPluginApi = { id: string; From ecc688d20552f11a8e8f17aa48a1382133db346d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:29:08 -0700 Subject: [PATCH 132/943] Google Chat: split setup adapter helpers --- extensions/googlechat/src/channel.ts | 3 +- extensions/googlechat/src/setup-core.ts | 67 ++++++++++++++++++++++ extensions/googlechat/src/setup-surface.ts | 63 +------------------- src/plugin-sdk/googlechat.ts | 6 +- 4 files changed, 74 insertions(+), 65 deletions(-) create mode 100644 extensions/googlechat/src/setup-core.ts diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 9ea172091f1..5d2c9d86748 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -35,7 +35,8 @@ import { } from "./accounts.js"; import { googlechatMessageActions } from "./actions.js"; import { getGoogleChatRuntime } from "./runtime.js"; -import { googlechatSetupAdapter, googlechatSetupWizard } from "./setup-surface.js"; +import { googlechatSetupAdapter } from "./setup-core.js"; +import { googlechatSetupWizard } from "./setup-surface.js"; import { isGoogleChatSpaceTarget, isGoogleChatUserTarget, diff --git a/extensions/googlechat/src/setup-core.ts b/extensions/googlechat/src/setup-core.ts new file mode 100644 index 00000000000..d4d2de49e06 --- /dev/null +++ b/extensions/googlechat/src/setup-core.ts @@ -0,0 +1,67 @@ +import { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; + +const channel = "googlechat" as const; + +export const googlechatSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account."; + } + if (!input.useEnv && !input.token && !input.tokenFile) { + return "Google Chat requires --token (service account JSON) or --token-file."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + const patch = input.useEnv + ? {} + : input.tokenFile + ? { serviceAccountFile: input.tokenFile } + : input.token + ? { serviceAccount: input.token } + : {}; + const audienceType = input.audienceType?.trim(); + const audience = input.audience?.trim(); + const webhookPath = input.webhookPath?.trim(); + const webhookUrl = input.webhookUrl?.trim(); + return applySetupAccountConfigPatch({ + cfg: next, + channelKey: channel, + accountId, + patch: { + ...patch, + ...(audienceType ? { audienceType } : {}), + ...(audience ? { audience } : {}), + ...(webhookPath ? { webhookPath } : {}), + ...(webhookUrl ? { webhookUrl } : {}), + }, + }); + }, +}; diff --git a/extensions/googlechat/src/setup-surface.ts b/extensions/googlechat/src/setup-surface.ts index e812561f674..64fe7837fa3 100644 --- a/extensions/googlechat/src/setup-surface.ts +++ b/extensions/googlechat/src/setup-surface.ts @@ -6,21 +6,20 @@ import { splitOnboardingEntries, } from "../../../src/channels/plugins/onboarding/helpers.js"; import { - applyAccountNameToChannelSection, applySetupAccountConfigPatch, migrateBaseNameToDefaultAccount, } from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DmPolicy } from "../../../src/config/types.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import { listGoogleChatAccountIds, resolveDefaultGoogleChatAccountId, resolveGoogleChatAccount, } from "./accounts.js"; +import { googlechatSetupAdapter } from "./setup-core.js"; const channel = "googlechat" as const; const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT"; @@ -87,63 +86,7 @@ const googlechatDmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom, }; -export const googlechatSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account."; - } - if (!input.useEnv && !input.token && !input.tokenFile) { - return "Google Chat requires --token (service account JSON) or --token-file."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - const patch = input.useEnv - ? {} - : input.tokenFile - ? { serviceAccountFile: input.tokenFile } - : input.token - ? { serviceAccount: input.token } - : {}; - const audienceType = input.audienceType?.trim(); - const audience = input.audience?.trim(); - const webhookPath = input.webhookPath?.trim(); - const webhookUrl = input.webhookUrl?.trim(); - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: channel, - accountId, - patch: { - ...patch, - ...(audienceType ? { audienceType } : {}), - ...(audience ? { audience } : {}), - ...(webhookPath ? { webhookPath } : {}), - ...(webhookUrl ? { webhookUrl } : {}), - }, - }); - }, -}; +export { googlechatSetupAdapter } from "./setup-core.js"; export const googlechatSetupWizard: ChannelSetupWizard = { channel, diff --git a/src/plugin-sdk/googlechat.ts b/src/plugin-sdk/googlechat.ts index e6e9aaefb1c..464af58776b 100644 --- a/src/plugin-sdk/googlechat.ts +++ b/src/plugin-sdk/googlechat.ts @@ -67,10 +67,8 @@ export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.j export { resolveDmGroupAccessWithLists } from "../security/dm-policy-shared.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export { - googlechatSetupAdapter, - googlechatSetupWizard, -} from "../../extensions/googlechat/src/setup-surface.js"; +export { googlechatSetupAdapter } from "../../extensions/googlechat/src/setup-core.js"; +export { googlechatSetupWizard } from "../../extensions/googlechat/src/setup-surface.js"; export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "./inbound-envelope.js"; export { createScopedPairingAccess } from "./pairing-access.js"; export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; From 7212b5f01a1056efd94b0d53ba45dda9ebc24650 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:31:11 -0700 Subject: [PATCH 133/943] Matrix: split setup adapter helpers --- extensions/matrix/src/channel.ts | 3 +- extensions/matrix/src/setup-core.ts | 111 ++++++++++++++++++++++++ extensions/matrix/src/setup-surface.ts | 114 +------------------------ src/plugin-sdk/matrix.ts | 6 +- 4 files changed, 119 insertions(+), 115 deletions(-) create mode 100644 extensions/matrix/src/setup-core.ts diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 8e3c858ecde..5d6f2a9d9b2 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -29,7 +29,8 @@ import { } from "./matrix/accounts.js"; import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js"; import { getMatrixRuntime } from "./runtime.js"; -import { matrixSetupAdapter, matrixSetupWizard } from "./setup-surface.js"; +import { matrixSetupAdapter } from "./setup-core.js"; +import { matrixSetupWizard } from "./setup-surface.js"; import type { CoreConfig } from "./types.js"; // Mutex for serializing account startup (workaround for concurrent dynamic import race condition) diff --git a/extensions/matrix/src/setup-core.ts b/extensions/matrix/src/setup-core.ts new file mode 100644 index 00000000000..f0fc395a344 --- /dev/null +++ b/extensions/matrix/src/setup-core.ts @@ -0,0 +1,111 @@ +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { normalizeSecretInputString } from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import type { CoreConfig } from "./types.js"; + +const channel = "matrix" as const; + +export function buildMatrixConfigUpdate( + cfg: CoreConfig, + input: { + homeserver?: string; + userId?: string; + accessToken?: string; + password?: string; + deviceName?: string; + initialSyncLimit?: number; + }, +): CoreConfig { + const existing = cfg.channels?.matrix ?? {}; + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...existing, + enabled: true, + ...(input.homeserver ? { homeserver: input.homeserver } : {}), + ...(input.userId ? { userId: input.userId } : {}), + ...(input.accessToken ? { accessToken: input.accessToken } : {}), + ...(input.password ? { password: input.password } : {}), + ...(input.deviceName ? { deviceName: input.deviceName } : {}), + ...(typeof input.initialSyncLimit === "number" + ? { initialSyncLimit: input.initialSyncLimit } + : {}), + }, + }, + }; +} + +export const matrixSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg: cfg as CoreConfig, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ input }) => { + if (input.useEnv) { + return null; + } + if (!input.homeserver?.trim()) { + return "Matrix requires --homeserver"; + } + const accessToken = input.accessToken?.trim(); + const password = normalizeSecretInputString(input.password); + const userId = input.userId?.trim(); + if (!accessToken && !password) { + return "Matrix requires --access-token or --password"; + } + if (!accessToken) { + if (!userId) { + return "Matrix requires --user-id when using --password"; + } + if (!password) { + return "Matrix requires --password when using --user-id"; + } + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg: cfg as CoreConfig, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (input.useEnv) { + return { + ...next, + channels: { + ...next.channels, + matrix: { + ...next.channels?.matrix, + enabled: true, + }, + }, + } as CoreConfig; + } + return buildMatrixConfigUpdate(next as CoreConfig, { + homeserver: input.homeserver?.trim(), + userId: input.userId?.trim(), + accessToken: input.accessToken?.trim(), + password: normalizeSecretInputString(input.password), + deviceName: input.deviceName?.trim(), + initialSyncLimit: input.initialSyncLimit, + }); + }, +}; diff --git a/extensions/matrix/src/setup-surface.ts b/extensions/matrix/src/setup-surface.ts index 9f37f000c46..e01e0d57750 100644 --- a/extensions/matrix/src/setup-surface.ts +++ b/extensions/matrix/src/setup-surface.ts @@ -7,63 +7,24 @@ import { promptSingleChannelSecretInput, setTopLevelChannelGroupPolicy, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DmPolicy } from "../../../src/config/types.js"; import type { SecretInput } from "../../../src/config/types.secrets.js"; -import { - hasConfiguredSecretInput, - normalizeSecretInputString, -} from "../../../src/config/types.secrets.js"; +import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; import { formatResolvedUnresolvedNote } from "../../../src/plugin-sdk/resolution-notes.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; import { resolveMatrixAccount } from "./matrix/accounts.js"; import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js"; import { resolveMatrixTargets } from "./resolve-targets.js"; +import { buildMatrixConfigUpdate, matrixSetupAdapter } from "./setup-core.js"; import type { CoreConfig } from "./types.js"; const channel = "matrix" as const; -function buildMatrixConfigUpdate( - cfg: CoreConfig, - input: { - homeserver?: string; - userId?: string; - accessToken?: string; - password?: string; - deviceName?: string; - initialSyncLimit?: number; - }, -): CoreConfig { - const existing = cfg.channels?.matrix ?? {}; - return { - ...cfg, - channels: { - ...cfg.channels, - matrix: { - ...existing, - enabled: true, - ...(input.homeserver ? { homeserver: input.homeserver } : {}), - ...(input.userId ? { userId: input.userId } : {}), - ...(input.accessToken ? { accessToken: input.accessToken } : {}), - ...(input.password ? { password: input.password } : {}), - ...(input.deviceName ? { deviceName: input.deviceName } : {}), - ...(typeof input.initialSyncLimit === "number" - ? { initialSyncLimit: input.initialSyncLimit } - : {}), - }, - }, - }; -} - function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) { const allowFrom = policy === "open" ? addWildcardAllowFrom(cfg.channels?.matrix?.dm?.allowFrom) : undefined; @@ -220,74 +181,7 @@ const matrixDmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptMatrixAllowFrom, }; -export const matrixSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg: cfg as CoreConfig, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ input }) => { - if (input.useEnv) { - return null; - } - if (!input.homeserver?.trim()) { - return "Matrix requires --homeserver"; - } - const accessToken = input.accessToken?.trim(); - const password = normalizeSecretInputString(input.password); - const userId = input.userId?.trim(); - if (!accessToken && !password) { - return "Matrix requires --access-token or --password"; - } - if (!accessToken) { - if (!userId) { - return "Matrix requires --user-id when using --password"; - } - if (!password) { - return "Matrix requires --password when using --user-id"; - } - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg: cfg as CoreConfig, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (input.useEnv) { - return { - ...next, - channels: { - ...next.channels, - matrix: { - ...next.channels?.matrix, - enabled: true, - }, - }, - } as CoreConfig; - } - return buildMatrixConfigUpdate(next as CoreConfig, { - homeserver: input.homeserver?.trim(), - userId: input.userId?.trim(), - accessToken: input.accessToken?.trim(), - password: normalizeSecretInputString(input.password), - deviceName: input.deviceName?.trim(), - initialSyncLimit: input.initialSyncLimit, - }); - }, -}; +export { matrixSetupAdapter } from "./setup-core.js"; export const matrixSetupWizard: ChannelSetupWizard = { channel, diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index 52d18e4665f..8a62aa9ae10 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -108,7 +108,5 @@ export { buildProbeChannelStatusSummary, collectStatusIssuesFromLastError, } from "./status-helpers.js"; -export { - matrixSetupAdapter, - matrixSetupWizard, -} from "../../extensions/matrix/src/setup-surface.js"; +export { matrixSetupWizard } from "../../extensions/matrix/src/setup-surface.js"; +export { matrixSetupAdapter } from "../../extensions/matrix/src/setup-core.js"; From 0c9428a865899f7c6abe45af0131bdebf6d1d10b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:32:48 -0700 Subject: [PATCH 134/943] MSTeams: split setup adapter helpers --- extensions/msteams/src/channel.ts | 3 ++- extensions/msteams/src/setup-core.ts | 16 ++++++++++++++++ extensions/msteams/src/setup-surface.ts | 16 ++-------------- src/plugin-sdk/msteams.ts | 6 ++---- 4 files changed, 22 insertions(+), 19 deletions(-) create mode 100644 extensions/msteams/src/setup-core.ts diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index a4e62e5e310..f87f239166c 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -26,7 +26,8 @@ import { resolveMSTeamsUserAllowlist, } from "./resolve-allowlist.js"; import { getMSTeamsRuntime } from "./runtime.js"; -import { msteamsSetupAdapter, msteamsSetupWizard } from "./setup-surface.js"; +import { msteamsSetupAdapter } from "./setup-core.js"; +import { msteamsSetupWizard } from "./setup-surface.js"; import { resolveMSTeamsCredentials } from "./token.js"; type ResolvedMSTeamsAccount = { diff --git a/extensions/msteams/src/setup-core.ts b/extensions/msteams/src/setup-core.ts new file mode 100644 index 00000000000..74079aaf389 --- /dev/null +++ b/extensions/msteams/src/setup-core.ts @@ -0,0 +1,16 @@ +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; + +export const msteamsSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountConfig: ({ cfg }) => ({ + ...cfg, + channels: { + ...cfg.channels, + msteams: { + ...cfg.channels?.msteams, + enabled: true, + }, + }, + }), +}; diff --git a/extensions/msteams/src/setup-surface.ts b/extensions/msteams/src/setup-surface.ts index 8d5ebdbb5ef..f8db90e5079 100644 --- a/extensions/msteams/src/setup-surface.ts +++ b/extensions/msteams/src/setup-surface.ts @@ -8,7 +8,6 @@ import { splitOnboardingEntries, } from "../../../src/channels/plugins/onboarding/helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DmPolicy, MSTeamsTeamConfig } from "../../../src/config/types.js"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; @@ -20,6 +19,7 @@ import { resolveMSTeamsUserAllowlist, } from "./resolve-allowlist.js"; import { normalizeSecretInputString } from "./secret-input.js"; +import { msteamsSetupAdapter } from "./setup-core.js"; import { hasConfiguredMSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js"; const channel = "msteams" as const; @@ -201,19 +201,7 @@ const msteamsDmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptMSTeamsAllowFrom, }; -export const msteamsSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: () => DEFAULT_ACCOUNT_ID, - applyAccountConfig: ({ cfg }) => ({ - ...cfg, - channels: { - ...cfg.channels, - msteams: { - ...cfg.channels?.msteams, - enabled: true, - }, - }, - }), -}; +export { msteamsSetupAdapter } from "./setup-core.js"; export const msteamsSetupWizard: ChannelSetupWizard = { channel, diff --git a/src/plugin-sdk/msteams.ts b/src/plugin-sdk/msteams.ts index d99f703ed64..2f5a91d8989 100644 --- a/src/plugin-sdk/msteams.ts +++ b/src/plugin-sdk/msteams.ts @@ -117,7 +117,5 @@ export { createDefaultChannelRuntimeState, } from "./status-helpers.js"; export { normalizeStringEntries } from "../shared/string-normalization.js"; -export { - msteamsSetupAdapter, - msteamsSetupWizard, -} from "../../extensions/msteams/src/setup-surface.js"; +export { msteamsSetupWizard } from "../../extensions/msteams/src/setup-surface.js"; +export { msteamsSetupAdapter } from "../../extensions/msteams/src/setup-core.js"; From a516141bdae5f3a4136f10ce1491452aa0e77f8e Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 16 Mar 2026 07:49:13 +0530 Subject: [PATCH 135/943] feat(telegram): add topic-edit action --- extensions/telegram/src/channel-actions.ts | 27 +++++++++ extensions/telegram/src/send.test.ts | 37 ++++++++++++ extensions/telegram/src/send.ts | 63 ++++++++++++++++---- src/agents/tools/telegram-actions.test.ts | 17 ++++++ src/agents/tools/telegram-actions.ts | 32 ++++++++++ src/channels/plugins/actions/actions.test.ts | 33 ++++++++++ src/channels/plugins/message-action-names.ts | 1 + src/config/types.telegram.ts | 2 + src/config/zod-schema.providers-core.ts | 1 + src/infra/outbound/message-action-spec.ts | 1 + 10 files changed, 204 insertions(+), 10 deletions(-) diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index 29095e7bc7c..1745071c060 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -115,6 +115,9 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { if (isEnabled("createForumTopic")) { actions.add("topic-create"); } + if (isEnabled("editForumTopic")) { + actions.add("topic-edit"); + } return Array.from(actions); }, supportsButtons: ({ cfg }) => { @@ -290,6 +293,30 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { ); } + if (action === "topic-edit") { + const chatId = readTelegramChatIdParam(params); + const messageThreadId = + readNumberParam(params, "messageThreadId", { integer: true }) ?? + readNumberParam(params, "threadId", { integer: true }); + if (typeof messageThreadId !== "number") { + throw new Error("messageThreadId or threadId is required."); + } + const name = readStringParam(params, "name"); + const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId"); + return await handleTelegramAction( + { + action: "editForumTopic", + chatId, + messageThreadId, + name: name ?? undefined, + iconCustomEmojiId: iconCustomEmojiId ?? undefined, + accountId: accountId ?? undefined, + }, + cfg, + { mediaLocalRoots }, + ); + } + throw new Error(`Action ${action} is not supported for provider ${providerId}.`); }, }; diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index 8a234ce92cb..ba1863b1b90 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -15,6 +15,7 @@ const { botApi, botCtorSpy, loadConfig, loadWebMedia, maybePersistResolvedTelegr const { buildInlineKeyboard, createForumTopicTelegram, + editForumTopicTelegram, editMessageTelegram, pinMessageTelegram, reactMessageTelegram, @@ -257,6 +258,42 @@ describe("sendMessageTelegram", () => { }); }); + it("edits a Telegram forum topic name and icon via the shared helper", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + botToken: "tok", + }, + }, + }); + botApi.editForumTopic.mockResolvedValue(true); + + await editForumTopicTelegram("-1001234567890", 271, { + accountId: "default", + name: "Codex Thread", + iconCustomEmojiId: "emoji-123", + }); + + expect(botApi.editForumTopic).toHaveBeenCalledWith("-1001234567890", 271, { + name: "Codex Thread", + icon_custom_emoji_id: "emoji-123", + }); + }); + + it("rejects empty topic edits", async () => { + await expect( + editForumTopicTelegram("-1001234567890", 271, { + accountId: "default", + }), + ).rejects.toThrow("Telegram forum topic update requires a name or iconCustomEmojiId"); + await expect( + editForumTopicTelegram("-1001234567890", 271, { + accountId: "default", + iconCustomEmojiId: " ", + }), + ).rejects.toThrow("Telegram forum topic icon custom emoji ID is required"); + }); + it("applies timeoutSeconds config precedence", async () => { const cases = [ { diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index 89d6f7d337d..d96e783c51d 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -1128,19 +1128,39 @@ export async function unpinMessageTelegram( }; } -export async function renameForumTopicTelegram( +type TelegramEditForumTopicOpts = TelegramDeleteOpts & { + name?: string; + iconCustomEmojiId?: string; +}; + +export async function editForumTopicTelegram( chatIdInput: string | number, messageThreadIdInput: string | number, - name: string, - opts: TelegramDeleteOpts = {}, -): Promise<{ ok: true; chatId: string; messageThreadId: number; name: string }> { - const trimmedName = name.trim(); - if (!trimmedName) { + opts: TelegramEditForumTopicOpts = {}, +): Promise<{ + ok: true; + chatId: string; + messageThreadId: number; + name?: string; + iconCustomEmojiId?: string; +}> { + const nameProvided = opts.name !== undefined; + const trimmedName = opts.name?.trim(); + if (nameProvided && !trimmedName) { throw new Error("Telegram forum topic name is required"); } - if (trimmedName.length > 128) { + if (trimmedName && trimmedName.length > 128) { throw new Error("Telegram forum topic name must be 128 characters or fewer"); } + const iconProvided = opts.iconCustomEmojiId !== undefined; + const trimmedIconCustomEmojiId = opts.iconCustomEmojiId?.trim(); + if (iconProvided && !trimmedIconCustomEmojiId) { + throw new Error("Telegram forum topic icon custom emoji ID is required"); + } + if (!trimmedName && !trimmedIconCustomEmojiId) { + throw new Error("Telegram forum topic update requires a name or iconCustomEmojiId"); + } + const { cfg, account, api } = resolveTelegramApiContext(opts); const rawTarget = String(chatIdInput); const chatId = await resolveAndPersistChatId({ @@ -1157,16 +1177,39 @@ export async function renameForumTopicTelegram( retry: opts.retry, verbose: opts.verbose, }); + const payload = { + ...(trimmedName ? { name: trimmedName } : {}), + ...(trimmedIconCustomEmojiId ? { icon_custom_emoji_id: trimmedIconCustomEmojiId } : {}), + }; await requestWithDiag( - () => api.editForumTopic(chatId, messageThreadId, { name: trimmedName }), + () => api.editForumTopic(chatId, messageThreadId, payload), "editForumTopic", ); - logVerbose(`[telegram] Renamed forum topic ${messageThreadId} in chat ${chatId}`); + logVerbose(`[telegram] Edited forum topic ${messageThreadId} in chat ${chatId}`); return { ok: true, chatId, messageThreadId, - name: trimmedName, + ...(trimmedName ? { name: trimmedName } : {}), + ...(trimmedIconCustomEmojiId ? { iconCustomEmojiId: trimmedIconCustomEmojiId } : {}), + }; +} + +export async function renameForumTopicTelegram( + chatIdInput: string | number, + messageThreadIdInput: string | number, + name: string, + opts: TelegramDeleteOpts = {}, +): Promise<{ ok: true; chatId: string; messageThreadId: number; name: string }> { + const result = await editForumTopicTelegram(chatIdInput, messageThreadIdInput, { + ...opts, + name, + }); + return { + ok: true, + chatId: result.chatId, + messageThreadId: result.messageThreadId, + name: result.name ?? name.trim(), }; } diff --git a/src/agents/tools/telegram-actions.test.ts b/src/agents/tools/telegram-actions.test.ts index 5963a64b667..997de707765 100644 --- a/src/agents/tools/telegram-actions.test.ts +++ b/src/agents/tools/telegram-actions.test.ts @@ -23,6 +23,12 @@ const editMessageTelegram = vi.fn(async () => ({ messageId: "456", chatId: "123", })); +const editForumTopicTelegram = vi.fn(async () => ({ + ok: true, + chatId: "123", + messageThreadId: 42, + name: "Renamed", +})); const createForumTopicTelegram = vi.fn(async () => ({ topicId: 99, name: "Topic", @@ -42,6 +48,8 @@ vi.mock("../../../extensions/telegram/src/send.js", () => ({ deleteMessageTelegram(...args), editMessageTelegram: (...args: Parameters) => editMessageTelegram(...args), + editForumTopicTelegram: (...args: Parameters) => + editForumTopicTelegram(...args), createForumTopicTelegram: (...args: Parameters) => createForumTopicTelegram(...args), })); @@ -105,6 +113,7 @@ describe("handleTelegramAction", () => { sendStickerTelegram.mockClear(); deleteMessageTelegram.mockClear(); editMessageTelegram.mockClear(); + editForumTopicTelegram.mockClear(); createForumTopicTelegram.mockClear(); process.env.TELEGRAM_BOT_TOKEN = "tok"; }); @@ -457,6 +466,14 @@ describe("handleTelegramAction", () => { readCallOpts: (calls: unknown[][], argIndex: number) => Record, ) => readCallOpts(createForumTopicTelegram.mock.calls as unknown[][], 2), }, + { + name: "editForumTopic", + params: { action: "editForumTopic", chatId: "123", messageThreadId: 42, name: "New" }, + cfg: telegramConfig({ actions: { editForumTopic: true } }), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(editForumTopicTelegram.mock.calls as unknown[][], 2), + }, ])("forwards resolved cfg for $name action", async ({ params, cfg, assertCall }) => { const readCallOpts = (calls: unknown[][], argIndex: number): Record => { const args = calls[0]; diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index 6c8d4f84204..ccfc9d5ae13 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -15,6 +15,7 @@ import { resolveTelegramReactionLevel } from "../../../extensions/telegram/src/r import { createForumTopicTelegram, deleteMessageTelegram, + editForumTopicTelegram, editMessageTelegram, reactMessageTelegram, sendMessageTelegram, @@ -478,5 +479,36 @@ export async function handleTelegramAction( }); } + if (action === "editForumTopic") { + if (!isActionEnabled("editForumTopic")) { + throw new Error("Telegram editForumTopic is disabled."); + } + const chatId = readStringOrNumberParam(params, "chatId", { + required: true, + }); + const messageThreadId = + readNumberParam(params, "messageThreadId", { integer: true }) ?? + readNumberParam(params, "threadId", { integer: true }); + if (typeof messageThreadId !== "number") { + throw new Error("messageThreadId or threadId is required."); + } + const name = readStringParam(params, "name"); + const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId"); + const token = resolveTelegramToken(cfg, { accountId }).token; + if (!token) { + throw new Error( + "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", + ); + } + const result = await editForumTopicTelegram(chatId ?? "", messageThreadId, { + cfg, + token, + accountId: accountId ?? undefined, + name: name ?? undefined, + iconCustomEmojiId: iconCustomEmojiId ?? undefined, + }); + return jsonResult(result); + } + throw new Error(`Unsupported Telegram action: ${action}`); } diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 055d660524f..bf75f9997d2 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -540,6 +540,21 @@ describe("telegramMessageActions", () => { expect(actions).toContain("poll"); }); + it("lists topic-edit when telegram topic edits are enabled", () => { + const cfg = { + channels: { + telegram: { + botToken: "tok", + actions: { editForumTopic: true }, + }, + }, + } as OpenClawConfig; + + const actions = telegramMessageActions.listActions?.({ cfg }) ?? []; + + expect(actions).toContain("topic-edit"); + }); + it("omits poll when sendMessage is disabled", () => { const cfg = { channels: { @@ -793,6 +808,24 @@ describe("telegramMessageActions", () => { accountId: undefined, }, }, + { + name: "topic-edit maps to editForumTopic", + action: "topic-edit" as const, + params: { + to: "telegram:group:-1001234567890:topic:271", + threadId: 271, + name: "Build Updates", + iconCustomEmojiId: "emoji-123", + }, + expectedPayload: { + action: "editForumTopic", + chatId: "telegram:group:-1001234567890:topic:271", + messageThreadId: 271, + name: "Build Updates", + iconCustomEmojiId: "emoji-123", + accountId: undefined, + }, + }, ] as const; for (const testCase of cases) { diff --git a/src/channels/plugins/message-action-names.ts b/src/channels/plugins/message-action-names.ts index 809d239be2c..aadff95c77d 100644 --- a/src/channels/plugins/message-action-names.ts +++ b/src/channels/plugins/message-action-names.ts @@ -44,6 +44,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [ "category-edit", "category-delete", "topic-create", + "topic-edit", "voice-status", "event-list", "event-create", diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 252f66740b2..fe1c5be3962 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -26,6 +26,8 @@ export type TelegramActionConfig = { sticker?: boolean; /** Enable forum topic creation. */ createForumTopic?: boolean; + /** Enable forum topic editing (rename / change icon). */ + editForumTopic?: boolean; }; export type TelegramNetworkConfig = { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 5f7dd7b8e48..da81ef61a4f 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -258,6 +258,7 @@ export const TelegramAccountSchemaBase = z editMessage: z.boolean().optional(), sticker: z.boolean().optional(), createForumTopic: z.boolean().optional(), + editForumTopic: z.boolean().optional(), }) .strict() .optional(), diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index b49a60c6991..f4f715d869d 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -49,6 +49,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record Date: Mon, 16 Mar 2026 07:58:33 +0530 Subject: [PATCH 136/943] fix(telegram): normalize topic-edit targets --- extensions/telegram/src/send.test.ts | 20 ++++++++++++++++++++ extensions/telegram/src/send.ts | 3 ++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index ba1863b1b90..78804cac8a8 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -280,6 +280,26 @@ describe("sendMessageTelegram", () => { }); }); + it("strips topic suffixes before editing a Telegram forum topic", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + botToken: "tok", + }, + }, + }); + botApi.editForumTopic.mockResolvedValue(true); + + await editForumTopicTelegram("telegram:group:-1001234567890:topic:271", 271, { + accountId: "default", + name: "Codex Thread", + }); + + expect(botApi.editForumTopic).toHaveBeenCalledWith("-1001234567890", 271, { + name: "Codex Thread", + }); + }); + it("rejects empty topic edits", async () => { await expect( editForumTopicTelegram("-1001234567890", 271, { diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index d96e783c51d..b215be835e8 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -1163,10 +1163,11 @@ export async function editForumTopicTelegram( const { cfg, account, api } = resolveTelegramApiContext(opts); const rawTarget = String(chatIdInput); + const target = parseTelegramTarget(rawTarget); const chatId = await resolveAndPersistChatId({ cfg, api, - lookupTarget: rawTarget, + lookupTarget: target.chatId, persistTarget: rawTarget, verbose: opts.verbose, }); From c08796b0394c2767d6c25e6208a8beee40752c40 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 16 Mar 2026 08:01:26 +0530 Subject: [PATCH 137/943] fix: add Telegram topic-edit action (#47798) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20d0b32ae92..f46e450d164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - Plugins/MiniMax: merge the bundled MiniMax API and MiniMax OAuth plugin surfaces into a single default-on `minimax` plugin, while keeping legacy `minimax-portal-auth` config ids aliased for compatibility. - Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized. - Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc. +- Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus. ### Fixes From 61bcdcca9c43c6e00ca688f436c53a62f7c4bd06 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:34:58 -0700 Subject: [PATCH 138/943] Feishu: split setup adapter helpers --- extensions/feishu/src/channel.ts | 3 +- extensions/feishu/src/setup-core.ts | 48 ++++++++++++++++++++++++++ extensions/feishu/src/setup-surface.ts | 46 ++---------------------- src/plugin-sdk/feishu.ts | 6 ++-- 4 files changed, 54 insertions(+), 49 deletions(-) create mode 100644 extensions/feishu/src/setup-core.ts diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 7d8560d5182..034b9b7c6a1 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -25,7 +25,8 @@ import { FeishuConfigSchema } from "./config-schema.js"; import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js"; import { resolveFeishuGroupToolPolicy } from "./policy.js"; import { getFeishuRuntime } from "./runtime.js"; -import { feishuSetupAdapter, feishuSetupWizard } from "./setup-surface.js"; +import { feishuSetupAdapter } from "./setup-core.js"; +import { feishuSetupWizard } from "./setup-surface.js"; import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js"; import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js"; diff --git a/extensions/feishu/src/setup-core.ts b/extensions/feishu/src/setup-core.ts new file mode 100644 index 00000000000..ada8ef79933 --- /dev/null +++ b/extensions/feishu/src/setup-core.ts @@ -0,0 +1,48 @@ +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import type { FeishuConfig } from "./types.js"; + +export function setFeishuNamedAccountEnabled( + cfg: OpenClawConfig, + accountId: string, + enabled: boolean, +): OpenClawConfig { + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...feishuCfg, + accounts: { + ...feishuCfg?.accounts, + [accountId]: { + ...feishuCfg?.accounts?.[accountId], + enabled, + }, + }, + }, + }, + }; +} + +export const feishuSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountConfig: ({ cfg, accountId }) => { + const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID; + if (isDefault) { + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + enabled: true, + }, + }, + }; + } + return setFeishuNamedAccountEnabled(cfg, accountId, true); + }, +}; diff --git a/extensions/feishu/src/setup-surface.ts b/extensions/feishu/src/setup-surface.ts index 1191a08e4e9..567ccea1a7e 100644 --- a/extensions/feishu/src/setup-surface.ts +++ b/extensions/feishu/src/setup-surface.ts @@ -9,7 +9,6 @@ import { splitOnboardingEntries, } from "../../../src/channels/plugins/onboarding/helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DmPolicy } from "../../../src/config/types.js"; import type { SecretInput } from "../../../src/config/types.secrets.js"; @@ -18,6 +17,7 @@ import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import { listFeishuAccountIds, resolveFeishuCredentials } from "./accounts.js"; import { probeFeishu } from "./probe.js"; +import { feishuSetupAdapter } from "./setup-core.js"; import type { FeishuConfig } from "./types.js"; const channel = "feishu" as const; @@ -30,30 +30,6 @@ function normalizeString(value: unknown): string | undefined { return trimmed || undefined; } -function setFeishuNamedAccountEnabled( - cfg: OpenClawConfig, - accountId: string, - enabled: boolean, -): OpenClawConfig { - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - return { - ...cfg, - channels: { - ...cfg.channels, - feishu: { - ...feishuCfg, - accounts: { - ...feishuCfg?.accounts, - [accountId]: { - ...feishuCfg?.accounts?.[accountId], - enabled, - }, - }, - }, - }, - }; -} - function setFeishuDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { return setTopLevelChannelDmPolicyWithAllowFrom({ cfg, @@ -211,25 +187,7 @@ const feishuDmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptFeishuAllowFrom, }; -export const feishuSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: () => DEFAULT_ACCOUNT_ID, - applyAccountConfig: ({ cfg, accountId }) => { - const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID; - if (isDefault) { - return { - ...cfg, - channels: { - ...cfg.channels, - feishu: { - ...cfg.channels?.feishu, - enabled: true, - }, - }, - }; - } - return setFeishuNamedAccountEnabled(cfg, accountId, true); - }, -}; +export { feishuSetupAdapter } from "./setup-core.js"; export const feishuSetupWizard: ChannelSetupWizard = { channel, diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 65f0773105b..246185f404e 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -62,10 +62,8 @@ export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export { evaluateSenderGroupAccessForPolicy } from "./group-access.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export { - feishuSetupAdapter, - feishuSetupWizard, -} from "../../extensions/feishu/src/setup-surface.js"; +export { feishuSetupWizard } from "../../extensions/feishu/src/setup-surface.js"; +export { feishuSetupAdapter } from "../../extensions/feishu/src/setup-core.js"; export { buildAgentMediaPayload } from "./agent-media-payload.js"; export { readJsonFileWithFallback } from "./json-store.js"; export { createScopedPairingAccess } from "./pairing-access.js"; From b37085984d56110b01fcfdb2120e1cf1f056155e Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:34:56 -0500 Subject: [PATCH 139/943] fixed main? --- extensions/zalouser/src/zca-client.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/extensions/zalouser/src/zca-client.ts b/extensions/zalouser/src/zca-client.ts index 00a1c8c1be0..f7bc1a358b3 100644 --- a/extensions/zalouser/src/zca-client.ts +++ b/extensions/zalouser/src/zca-client.ts @@ -1,16 +1,18 @@ -import { - LoginQRCallbackEventType as LoginQRCallbackEventTypeRuntime, - Reactions as ReactionsRuntime, - ThreadType as ThreadTypeRuntime, - Zalo as ZaloRuntime, -} from "zca-js"; +import * as zcaJsRuntime from "zca-js"; -export const ThreadType = ThreadTypeRuntime as { +const zcaJs = zcaJsRuntime as unknown as { + ThreadType: unknown; + LoginQRCallbackEventType: unknown; + Reactions: unknown; + Zalo: unknown; +}; + +export const ThreadType = zcaJs.ThreadType as { User: 0; Group: 1; }; -export const LoginQRCallbackEventType = LoginQRCallbackEventTypeRuntime as { +export const LoginQRCallbackEventType = zcaJs.LoginQRCallbackEventType as { QRCodeGenerated: 0; QRCodeExpired: 1; QRCodeScanned: 2; @@ -18,7 +20,7 @@ export const LoginQRCallbackEventType = LoginQRCallbackEventTypeRuntime as { GotLoginInfo: 4; }; -export const Reactions = ReactionsRuntime as Record & { +export const Reactions = zcaJs.Reactions as Record & { HEART: string; LIKE: string; HAHA: string; @@ -290,4 +292,4 @@ type ZaloCtor = new (options?: { logging?: boolean; selfListen?: boolean }) => { ): Promise; }; -export const Zalo = ZaloRuntime as unknown as ZaloCtor; +export const Zalo = zcaJs.Zalo as unknown as ZaloCtor; From 88b8151c524b4f0701fd0546c81a5e0707db81d5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:37:12 -0700 Subject: [PATCH 140/943] Zalo: split setup adapter helpers --- extensions/zalo/src/channel.ts | 3 +- extensions/zalo/src/setup-core.ts | 57 ++++++++++++++++++++++++++++ extensions/zalo/src/setup-surface.ts | 55 +-------------------------- src/plugin-sdk/zalo.ts | 3 +- 4 files changed, 63 insertions(+), 55 deletions(-) create mode 100644 extensions/zalo/src/setup-core.ts diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index adba1f8bd93..69f99c69e3a 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -40,7 +40,8 @@ import { probeZalo } from "./probe.js"; import { resolveZaloProxyFetch } from "./proxy.js"; import { normalizeSecretInputString } from "./secret-input.js"; import { sendMessageZalo } from "./send.js"; -import { zaloSetupAdapter, zaloSetupWizard } from "./setup-surface.js"; +import { zaloSetupAdapter } from "./setup-core.js"; +import { zaloSetupWizard } from "./setup-surface.js"; import { collectZaloStatusIssues } from "./status-issues.js"; const meta = { diff --git a/extensions/zalo/src/setup-core.ts b/extensions/zalo/src/setup-core.ts new file mode 100644 index 00000000000..6e194a41652 --- /dev/null +++ b/extensions/zalo/src/setup-core.ts @@ -0,0 +1,57 @@ +import { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; + +const channel = "zalo" as const; + +export const zaloSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "ZALO_BOT_TOKEN can only be used for the default account."; + } + if (!input.useEnv && !input.token && !input.tokenFile) { + return "Zalo requires token or --token-file (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + const patch = input.useEnv + ? {} + : input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}; + return applySetupAccountConfigPatch({ + cfg: next, + channelKey: channel, + accountId, + patch, + }); + }, +}; diff --git a/extensions/zalo/src/setup-surface.ts b/extensions/zalo/src/setup-surface.ts index 643c2f6ff76..125bc322998 100644 --- a/extensions/zalo/src/setup-surface.ts +++ b/extensions/zalo/src/setup-surface.ts @@ -6,19 +6,14 @@ import { runSingleChannelSecretStep, setTopLevelChannelDmPolicyWithAllowFrom, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { SecretInput } from "../../../src/config/types.secrets.js"; import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js"; +import { zaloSetupAdapter } from "./setup-core.js"; const channel = "zalo" as const; @@ -207,53 +202,7 @@ const zaloDmPolicy: ChannelOnboardingDmPolicy = { }, }; -export const zaloSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "ZALO_BOT_TOKEN can only be used for the default account."; - } - if (!input.useEnv && !input.token && !input.tokenFile) { - return "Zalo requires token or --token-file (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - const patch = input.useEnv - ? {} - : input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { botToken: input.token } - : {}; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: channel, - accountId, - patch, - }); - }, -}; +export { zaloSetupAdapter } from "./setup-core.js"; export const zaloSetupWizard: ChannelSetupWizard = { channel, diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index 4323ae4eb6e..307ea5f16f5 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -64,7 +64,8 @@ export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.j export type { RuntimeEnv } from "../runtime.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { formatAllowFromLowercase, isNormalizedSenderAllowed } from "./allow-from.js"; -export { zaloSetupAdapter, zaloSetupWizard } from "../../extensions/zalo/src/setup-surface.js"; +export { zaloSetupAdapter } from "../../extensions/zalo/src/setup-core.js"; +export { zaloSetupWizard } from "../../extensions/zalo/src/setup-surface.js"; export { resolveDirectDmAuthorizationOutcome, resolveSenderCommandAuthorizationWithRuntime, From b580d142cd56738c3f62f1152765b9e09b312691 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 19:38:13 -0700 Subject: [PATCH 141/943] refactor(plugins): split lightweight channel setup modules --- extensions/discord/setup-entry.ts | 4 +- extensions/discord/src/channel.setup.ts | 75 +++++++++ extensions/imessage/setup-entry.ts | 4 +- extensions/imessage/src/channel.setup.ts | 99 ++++++++++++ extensions/signal/setup-entry.ts | 4 +- extensions/signal/src/channel.setup.ts | 112 +++++++++++++ extensions/slack/setup-entry.ts | 4 +- extensions/slack/src/channel.setup.ts | 100 ++++++++++++ extensions/telegram/setup-entry.ts | 4 +- extensions/telegram/src/channel.setup.ts | 125 ++++++++++++++ extensions/whatsapp/setup-entry.ts | 4 +- extensions/whatsapp/src/channel.setup.ts | 198 +++++++++++++++++++++++ 12 files changed, 721 insertions(+), 12 deletions(-) create mode 100644 extensions/discord/src/channel.setup.ts create mode 100644 extensions/imessage/src/channel.setup.ts create mode 100644 extensions/signal/src/channel.setup.ts create mode 100644 extensions/slack/src/channel.setup.ts create mode 100644 extensions/telegram/src/channel.setup.ts create mode 100644 extensions/whatsapp/src/channel.setup.ts diff --git a/extensions/discord/setup-entry.ts b/extensions/discord/setup-entry.ts index 56673347d64..329a9376c9f 100644 --- a/extensions/discord/setup-entry.ts +++ b/extensions/discord/setup-entry.ts @@ -1,3 +1,3 @@ -import { discordPlugin } from "./src/channel.js"; +import { discordSetupPlugin } from "./src/channel.setup.js"; -export default { plugin: discordPlugin }; +export default { plugin: discordSetupPlugin }; diff --git a/extensions/discord/src/channel.setup.ts b/extensions/discord/src/channel.setup.ts new file mode 100644 index 00000000000..ac79acf443e --- /dev/null +++ b/extensions/discord/src/channel.setup.ts @@ -0,0 +1,75 @@ +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; +import { + createScopedAccountConfigAccessors, + formatAllowFromLowercase, +} from "openclaw/plugin-sdk/compat"; +import { + buildChannelConfigSchema, + DiscordConfigSchema, + getChatChannelMeta, + inspectDiscordAccount, + listDiscordAccountIds, + resolveDefaultDiscordAccountId, + resolveDiscordAccount, + type ChannelPlugin, + type ResolvedDiscordAccount, +} from "openclaw/plugin-sdk/discord"; +import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js"; + +async function loadDiscordChannelRuntime() { + return await import("./channel.runtime.js"); +} + +const discordConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom, + formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), + resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo, +}); + +const discordConfigBase = createScopedChannelConfigBase({ + sectionKey: "discord", + listAccountIds: listDiscordAccountIds, + resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultDiscordAccountId, + clearBaseFields: ["token", "name"], +}); + +const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({ + discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard, +})); + +export const discordSetupPlugin: ChannelPlugin = { + id: "discord", + meta: { + ...getChatChannelMeta("discord"), + }, + setupWizard: discordSetupWizard, + capabilities: { + chatTypes: ["direct", "channel", "thread"], + polls: true, + reactions: true, + threads: true, + media: true, + nativeCommands: true, + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.discord"] }, + configSchema: buildChannelConfigSchema(DiscordConfigSchema), + config: { + ...discordConfigBase, + isConfigured: (account) => Boolean(account.token?.trim()), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.token?.trim()), + tokenSource: account.tokenSource, + }), + ...discordConfigAccessors, + }, + setup: discordSetupAdapter, +}; diff --git a/extensions/imessage/setup-entry.ts b/extensions/imessage/setup-entry.ts index 4b0cc6203e2..6b4c642d0ae 100644 --- a/extensions/imessage/setup-entry.ts +++ b/extensions/imessage/setup-entry.ts @@ -1,3 +1,3 @@ -import { imessagePlugin } from "./src/channel.js"; +import { imessageSetupPlugin } from "./src/channel.setup.js"; -export default { plugin: imessagePlugin }; +export default { plugin: imessageSetupPlugin }; diff --git a/extensions/imessage/src/channel.setup.ts b/extensions/imessage/src/channel.setup.ts new file mode 100644 index 00000000000..075e50f0dda --- /dev/null +++ b/extensions/imessage/src/channel.setup.ts @@ -0,0 +1,99 @@ +import { + buildAccountScopedDmSecurityPolicy, + collectAllowlistProviderRestrictSendersWarnings, +} from "openclaw/plugin-sdk/compat"; +import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatTrimmedAllowFromEntries, + getChatChannelMeta, + IMessageConfigSchema, + listIMessageAccountIds, + resolveDefaultIMessageAccountId, + resolveIMessageAccount, + resolveIMessageConfigAllowFrom, + resolveIMessageConfigDefaultTo, + setAccountEnabledInConfigSection, + type ChannelPlugin, + type ResolvedIMessageAccount, +} from "openclaw/plugin-sdk/imessage"; +import { createIMessageSetupWizardProxy, imessageSetupAdapter } from "./setup-core.js"; + +async function loadIMessageChannelRuntime() { + return await import("./channel.runtime.js"); +} + +const imessageSetupWizard = createIMessageSetupWizardProxy(async () => ({ + imessageSetupWizard: (await loadIMessageChannelRuntime()).imessageSetupWizard, +})); + +export const imessageSetupPlugin: ChannelPlugin = { + id: "imessage", + meta: { + ...getChatChannelMeta("imessage"), + aliases: ["imsg"], + showConfigured: false, + }, + setupWizard: imessageSetupWizard, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + }, + reload: { configPrefixes: ["channels.imessage"] }, + configSchema: buildChannelConfigSchema(IMessageConfigSchema), + config: { + listAccountIds: (cfg) => listIMessageAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultIMessageAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "imessage", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "imessage", + accountId, + clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"], + }), + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + }), + resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }), + formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom), + resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => + buildAccountScopedDmSecurityPolicy({ + cfg, + channelKey: "imessage", + accountId, + fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + policy: account.config.dmPolicy, + allowFrom: account.config.allowFrom ?? [], + policyPathSuffix: "dmPolicy", + }), + collectWarnings: ({ account, cfg }) => + collectAllowlistProviderRestrictSendersWarnings({ + cfg, + providerConfigPresent: cfg.channels?.imessage !== undefined, + configuredGroupPolicy: account.config.groupPolicy, + surface: "iMessage groups", + openScope: "any member", + groupPolicyPath: "channels.imessage.groupPolicy", + groupAllowFromPath: "channels.imessage.groupAllowFrom", + mentionGated: false, + }), + }, + setup: imessageSetupAdapter, +}; diff --git a/extensions/signal/setup-entry.ts b/extensions/signal/setup-entry.ts index afe80451845..18c27ec5a16 100644 --- a/extensions/signal/setup-entry.ts +++ b/extensions/signal/setup-entry.ts @@ -1,3 +1,3 @@ -import { signalPlugin } from "./src/channel.js"; +import { signalSetupPlugin } from "./src/channel.setup.js"; -export default { plugin: signalPlugin }; +export default { plugin: signalSetupPlugin }; diff --git a/extensions/signal/src/channel.setup.ts b/extensions/signal/src/channel.setup.ts new file mode 100644 index 00000000000..544efa0f64f --- /dev/null +++ b/extensions/signal/src/channel.setup.ts @@ -0,0 +1,112 @@ +import { + createScopedAccountConfigAccessors, + buildAccountScopedDmSecurityPolicy, + collectAllowlistProviderRestrictSendersWarnings, +} from "openclaw/plugin-sdk/compat"; +import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + getChatChannelMeta, + listSignalAccountIds, + normalizeE164, + resolveDefaultSignalAccountId, + resolveSignalAccount, + setAccountEnabledInConfigSection, + SignalConfigSchema, + type ChannelPlugin, + type ResolvedSignalAccount, +} from "openclaw/plugin-sdk/signal"; +import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js"; + +async function loadSignalChannelRuntime() { + return await import("./channel.runtime.js"); +} + +const signalSetupWizard = createSignalSetupWizardProxy(async () => ({ + signalSetupWizard: (await loadSignalChannelRuntime()).signalSetupWizard, +})); + +const signalConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")))) + .filter(Boolean), + resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo, +}); + +export const signalSetupPlugin: ChannelPlugin = { + id: "signal", + meta: { + ...getChatChannelMeta("signal"), + }, + setupWizard: signalSetupWizard, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + reactions: true, + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.signal"] }, + configSchema: buildChannelConfigSchema(SignalConfigSchema), + config: { + listAccountIds: (cfg) => listSignalAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "signal", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "signal", + accountId, + clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"], + }), + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + baseUrl: account.baseUrl, + }), + ...signalConfigAccessors, + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => + buildAccountScopedDmSecurityPolicy({ + cfg, + channelKey: "signal", + accountId, + fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + policy: account.config.dmPolicy, + allowFrom: account.config.allowFrom ?? [], + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()), + }), + collectWarnings: ({ account, cfg }) => + collectAllowlistProviderRestrictSendersWarnings({ + cfg, + providerConfigPresent: cfg.channels?.signal !== undefined, + configuredGroupPolicy: account.config.groupPolicy, + surface: "Signal groups", + openScope: "any member", + groupPolicyPath: "channels.signal.groupPolicy", + groupAllowFromPath: "channels.signal.groupAllowFrom", + mentionGated: false, + }), + }, + setup: signalSetupAdapter, +}; diff --git a/extensions/slack/setup-entry.ts b/extensions/slack/setup-entry.ts index d219e597148..1bd6eabde59 100644 --- a/extensions/slack/setup-entry.ts +++ b/extensions/slack/setup-entry.ts @@ -1,3 +1,3 @@ -import { slackPlugin } from "./src/channel.js"; +import { slackSetupPlugin } from "./src/channel.setup.js"; -export default { plugin: slackPlugin }; +export default { plugin: slackSetupPlugin }; diff --git a/extensions/slack/src/channel.setup.ts b/extensions/slack/src/channel.setup.ts new file mode 100644 index 00000000000..2f7b888ca18 --- /dev/null +++ b/extensions/slack/src/channel.setup.ts @@ -0,0 +1,100 @@ +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; +import { + createScopedAccountConfigAccessors, + formatAllowFromLowercase, +} from "openclaw/plugin-sdk/compat"; +import { + buildChannelConfigSchema, + getChatChannelMeta, + inspectSlackAccount, + listSlackAccountIds, + resolveDefaultSlackAccountId, + resolveSlackAccount, + SlackConfigSchema, + type ChannelPlugin, + type ResolvedSlackAccount, +} from "openclaw/plugin-sdk/slack"; +import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; + +async function loadSlackChannelRuntime() { + return await import("./channel.runtime.js"); +} + +function isSlackAccountConfigured(account: ResolvedSlackAccount): boolean { + const mode = account.config.mode ?? "socket"; + const hasBotToken = Boolean(account.botToken?.trim()); + if (!hasBotToken) { + return false; + } + if (mode === "http") { + return Boolean(account.config.signingSecret?.trim()); + } + return Boolean(account.appToken?.trim()); +} + +const slackConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom, + formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), + resolveDefaultTo: (account: ResolvedSlackAccount) => account.config.defaultTo, +}); + +const slackConfigBase = createScopedChannelConfigBase({ + sectionKey: "slack", + listAccountIds: listSlackAccountIds, + resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultSlackAccountId, + clearBaseFields: ["botToken", "appToken", "name"], +}); + +const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ + slackSetupWizard: (await loadSlackChannelRuntime()).slackSetupWizard, +})); + +export const slackSetupPlugin: ChannelPlugin = { + id: "slack", + meta: { + ...getChatChannelMeta("slack"), + preferSessionLookupForAnnounceTarget: true, + }, + setupWizard: slackSetupWizard, + capabilities: { + chatTypes: ["direct", "channel", "thread"], + reactions: true, + threads: true, + media: true, + nativeCommands: true, + }, + agentPrompt: { + messageToolHints: ({ cfg, accountId }) => + cfg.channels?.slack?.accounts?.[accountId ?? "default"]?.capabilities?.interactiveReplies === + true || cfg.channels?.slack?.capabilities?.interactiveReplies === true + ? [ + "- Slack interactive replies: use `[[slack_buttons: Label:value, Other:other]]` to add action buttons that route clicks back as Slack interaction system events.", + "- Slack selects: use `[[slack_select: Placeholder | Label:value, Other:other]]` to add a static select menu that routes the chosen value back as a Slack interaction system event.", + ] + : [ + "- Slack interactive replies are disabled. If needed, ask to set `channels.slack.capabilities.interactiveReplies=true` (or the same under `channels.slack.accounts..capabilities`).", + ], + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.slack"] }, + configSchema: buildChannelConfigSchema(SlackConfigSchema), + config: { + ...slackConfigBase, + isConfigured: (account) => isSlackAccountConfigured(account), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: isSlackAccountConfigured(account), + botTokenSource: account.botTokenSource, + appTokenSource: account.appTokenSource, + }), + ...slackConfigAccessors, + }, + setup: slackSetupAdapter, +}; diff --git a/extensions/telegram/setup-entry.ts b/extensions/telegram/setup-entry.ts index b5e7fc8c073..030f4bb3295 100644 --- a/extensions/telegram/setup-entry.ts +++ b/extensions/telegram/setup-entry.ts @@ -1,3 +1,3 @@ -import { telegramPlugin } from "./src/channel.js"; +import { telegramSetupPlugin } from "./src/channel.setup.js"; -export default { plugin: telegramPlugin }; +export default { plugin: telegramSetupPlugin }; diff --git a/extensions/telegram/src/channel.setup.ts b/extensions/telegram/src/channel.setup.ts new file mode 100644 index 00000000000..6abc8ba0c62 --- /dev/null +++ b/extensions/telegram/src/channel.setup.ts @@ -0,0 +1,125 @@ +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; +import { + createScopedAccountConfigAccessors, + formatAllowFromLowercase, +} from "openclaw/plugin-sdk/compat"; +import { + buildChannelConfigSchema, + getChatChannelMeta, + inspectTelegramAccount, + listTelegramAccountIds, + normalizeAccountId, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, + TelegramConfigSchema, + type ChannelPlugin, + type OpenClawConfig, + type ResolvedTelegramAccount, + type TelegramProbe, +} from "openclaw/plugin-sdk/telegram"; +import { telegramSetupAdapter } from "./setup-core.js"; +import { telegramSetupWizard } from "./setup-surface.js"; + +function findTelegramTokenOwnerAccountId(params: { + cfg: OpenClawConfig; + accountId: string; +}): string | null { + const normalizedAccountId = normalizeAccountId(params.accountId); + const tokenOwners = new Map(); + for (const id of listTelegramAccountIds(params.cfg)) { + const account = inspectTelegramAccount({ cfg: params.cfg, accountId: id }); + const token = (account.token ?? "").trim(); + if (!token) { + continue; + } + const ownerAccountId = tokenOwners.get(token); + if (!ownerAccountId) { + tokenOwners.set(token, account.accountId); + continue; + } + if (account.accountId === normalizedAccountId) { + return ownerAccountId; + } + } + return null; +} + +function formatDuplicateTelegramTokenReason(params: { + accountId: string; + ownerAccountId: string; +}): string { + return ( + `Duplicate Telegram bot token: account "${params.accountId}" shares a token with ` + + `account "${params.ownerAccountId}". Keep one owner account per bot token.` + ); +} + +const telegramConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(telegram|tg):/i }), + resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo, +}); + +const telegramConfigBase = createScopedChannelConfigBase({ + sectionKey: "telegram", + listAccountIds: listTelegramAccountIds, + resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultTelegramAccountId, + clearBaseFields: ["botToken", "tokenFile", "name"], +}); + +export const telegramSetupPlugin: ChannelPlugin = { + id: "telegram", + meta: { + ...getChatChannelMeta("telegram"), + quickstartAllowFrom: true, + }, + setupWizard: telegramSetupWizard, + capabilities: { + chatTypes: ["direct", "group", "channel", "thread"], + reactions: true, + threads: true, + media: true, + polls: true, + nativeCommands: true, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.telegram"] }, + configSchema: buildChannelConfigSchema(TelegramConfigSchema), + config: { + ...telegramConfigBase, + isConfigured: (account, cfg) => { + if (!account.token?.trim()) { + return false; + } + return !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); + }, + unconfiguredReason: (account, cfg) => { + if (!account.token?.trim()) { + return "not configured"; + } + const ownerAccountId = findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); + if (!ownerAccountId) { + return "not configured"; + } + return formatDuplicateTelegramTokenReason({ + accountId: account.accountId, + ownerAccountId, + }); + }, + describeAccount: (account, cfg) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: + Boolean(account.token?.trim()) && + !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }), + tokenSource: account.tokenSource, + }), + ...telegramConfigAccessors, + }, + setup: telegramSetupAdapter, +}; diff --git a/extensions/whatsapp/setup-entry.ts b/extensions/whatsapp/setup-entry.ts index 0dd48c5b785..5b18e10073b 100644 --- a/extensions/whatsapp/setup-entry.ts +++ b/extensions/whatsapp/setup-entry.ts @@ -1,3 +1,3 @@ -import { whatsappPlugin } from "./src/channel.js"; +import { whatsappSetupPlugin } from "./src/channel.setup.js"; -export default { plugin: whatsappPlugin }; +export default { plugin: whatsappSetupPlugin }; diff --git a/extensions/whatsapp/src/channel.setup.ts b/extensions/whatsapp/src/channel.setup.ts new file mode 100644 index 00000000000..b352bd2ed73 --- /dev/null +++ b/extensions/whatsapp/src/channel.setup.ts @@ -0,0 +1,198 @@ +import { + buildAccountScopedDmSecurityPolicy, + buildChannelConfigSchema, + collectAllowlistProviderGroupPolicyWarnings, + collectOpenGroupPolicyRouteAllowlistWarnings, + DEFAULT_ACCOUNT_ID, + formatWhatsAppConfigAllowFromEntries, + getChatChannelMeta, + normalizeE164, + resolveWhatsAppConfigAllowFrom, + resolveWhatsAppConfigDefaultTo, + resolveWhatsAppGroupIntroHint, + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, + WhatsAppConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/whatsapp"; +import { + listWhatsAppAccountIds, + resolveDefaultWhatsAppAccountId, + resolveWhatsAppAccount, + type ResolvedWhatsAppAccount, +} from "./accounts.js"; +import { webAuthExists } from "./auth-store.js"; +import { whatsappSetupAdapter } from "./setup-core.js"; + +async function loadWhatsAppChannelRuntime() { + return await import("./channel.runtime.js"); +} + +const whatsappSetupWizardProxy = { + channel: "whatsapp", + status: { + configuredLabel: "linked", + unconfiguredLabel: "not linked", + configuredHint: "linked", + unconfiguredHint: "not linked", + configuredScore: 5, + unconfiguredScore: 4, + resolveConfigured: async ({ cfg }) => + await ( + await loadWhatsAppChannelRuntime() + ).whatsappSetupWizard.status.resolveConfigured({ + cfg, + }), + resolveStatusLines: async ({ cfg, configured }) => + (await ( + await loadWhatsAppChannelRuntime() + ).whatsappSetupWizard.status.resolveStatusLines?.({ + cfg, + configured, + })) ?? [], + }, + resolveShouldPromptAccountIds: (params) => + (params.shouldPromptAccountIds || params.options?.promptWhatsAppAccountId) ?? false, + credentials: [], + finalize: async (params) => + await ( + await loadWhatsAppChannelRuntime() + ).whatsappSetupWizard.finalize!(params), + disable: (cfg) => ({ + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + enabled: false, + }, + }, + }), + onAccountRecorded: (accountId, options) => { + options?.onWhatsAppAccountId?.(accountId); + }, +} satisfies NonNullable["setupWizard"]>; + +export const whatsappSetupPlugin: ChannelPlugin = { + id: "whatsapp", + meta: { + ...getChatChannelMeta("whatsapp"), + showConfigured: false, + quickstartAllowFrom: true, + forceAccountBinding: true, + preferSessionLookupForAnnounceTarget: true, + }, + setupWizard: whatsappSetupWizardProxy, + capabilities: { + chatTypes: ["direct", "group"], + polls: true, + reactions: true, + media: true, + }, + reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] }, + gatewayMethods: ["web.login.start", "web.login.wait"], + configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), + config: { + listAccountIds: (cfg) => listWhatsAppAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultWhatsAppAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => { + const accountKey = accountId || DEFAULT_ACCOUNT_ID; + const accounts = { ...cfg.channels?.whatsapp?.accounts }; + const existing = accounts[accountKey] ?? {}; + return { + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + accounts: { + ...accounts, + [accountKey]: { + ...existing, + enabled, + }, + }, + }, + }, + }; + }, + deleteAccount: ({ cfg, accountId }) => { + const accountKey = accountId || DEFAULT_ACCOUNT_ID; + const accounts = { ...cfg.channels?.whatsapp?.accounts }; + delete accounts[accountKey]; + return { + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + accounts: Object.keys(accounts).length ? accounts : undefined, + }, + }, + }; + }, + isEnabled: (account, cfg) => account.enabled && cfg.web?.enabled !== false, + disabledReason: () => "disabled", + isConfigured: async (account) => await webAuthExists(account.authDir), + unconfiguredReason: () => "not linked", + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.authDir), + linked: Boolean(account.authDir), + dmPolicy: account.dmPolicy, + allowFrom: account.allowFrom, + }), + resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppConfigAllowFrom({ cfg, accountId }), + formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom), + resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => + buildAccountScopedDmSecurityPolicy({ + cfg, + channelKey: "whatsapp", + accountId, + fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + policy: account.dmPolicy, + allowFrom: account.allowFrom ?? [], + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeE164(raw), + }), + collectWarnings: ({ account, cfg }) => { + const groupAllowlistConfigured = + Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0; + return collectAllowlistProviderGroupPolicyWarnings({ + cfg, + providerConfigPresent: cfg.channels?.whatsapp !== undefined, + configuredGroupPolicy: account.groupPolicy, + collect: (groupPolicy) => + collectOpenGroupPolicyRouteAllowlistWarnings({ + groupPolicy, + routeAllowlistConfigured: groupAllowlistConfigured, + restrictSenders: { + surface: "WhatsApp groups", + openScope: "any member in allowed groups", + groupPolicyPath: "channels.whatsapp.groupPolicy", + groupAllowFromPath: "channels.whatsapp.groupAllowFrom", + }, + noRouteAllowlist: { + surface: "WhatsApp groups", + routeAllowlistPath: "channels.whatsapp.groups", + routeScope: "group", + groupPolicyPath: "channels.whatsapp.groupPolicy", + groupAllowFromPath: "channels.whatsapp.groupAllowFrom", + }, + }), + }); + }, + }, + setup: whatsappSetupAdapter, + groups: { + resolveRequireMention: resolveWhatsAppGroupRequireMention, + resolveToolPolicy: resolveWhatsAppGroupToolPolicy, + resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, + }, +}; From dd203c8eee1bf724656ba9a3d45c85176d0bd5f9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:39:20 -0700 Subject: [PATCH 142/943] Zalouser: split setup adapter helpers --- extensions/zalouser/src/channel.ts | 3 +- extensions/zalouser/src/setup-core.ts | 42 ++++++++++++++++++++++++ extensions/zalouser/src/setup-surface.ts | 42 ++---------------------- src/plugin-sdk/zalouser.ts | 6 ++-- 4 files changed, 49 insertions(+), 44 deletions(-) create mode 100644 extensions/zalouser/src/setup-core.ts diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index b7d103e9b6e..46dbb2c9fee 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -42,7 +42,8 @@ import { probeZalouser } from "./probe.js"; import { writeQrDataUrlToTempFile } from "./qr-temp-file.js"; import { getZalouserRuntime } from "./runtime.js"; import { sendMessageZalouser, sendReactionZalouser } from "./send.js"; -import { zalouserSetupAdapter, zalouserSetupWizard } from "./setup-surface.js"; +import { zalouserSetupAdapter } from "./setup-core.js"; +import { zalouserSetupWizard } from "./setup-surface.js"; import { collectZalouserStatusIssues } from "./status-issues.js"; import { listZaloFriendsMatching, diff --git a/extensions/zalouser/src/setup-core.ts b/extensions/zalouser/src/setup-core.ts new file mode 100644 index 00000000000..45f412ed9f6 --- /dev/null +++ b/extensions/zalouser/src/setup-core.ts @@ -0,0 +1,42 @@ +import { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; + +const channel = "zalouser" as const; + +export const zalouserSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: () => null, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + return applySetupAccountConfigPatch({ + cfg: next, + channelKey: channel, + accountId, + patch: {}, + }); + }, +}; diff --git a/extensions/zalouser/src/setup-surface.ts b/extensions/zalouser/src/setup-surface.ts index b091ed37947..3ce0bd9d066 100644 --- a/extensions/zalouser/src/setup-surface.ts +++ b/extensions/zalouser/src/setup-surface.ts @@ -3,14 +3,8 @@ import { mergeAllowFromEntries, setTopLevelChannelDmPolicyWithAllowFrom, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, - migrateBaseNameToDefaultAccount, - patchScopedAccountConfig, -} from "../../../src/channels/plugins/setup-helpers.js"; +import { patchScopedAccountConfig } from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { formatResolvedUnresolvedNote } from "../../../src/plugin-sdk/resolution-notes.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; @@ -22,6 +16,7 @@ import { checkZcaAuthenticated, } from "./accounts.js"; import { writeQrDataUrlToTempFile } from "./qr-temp-file.js"; +import { zalouserSetupAdapter } from "./setup-core.js"; import { logoutZaloProfile, resolveZaloAllowFromEntries, @@ -169,38 +164,7 @@ const zalouserDmPolicy: ChannelOnboardingDmPolicy = { }, }; -export const zalouserSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: () => null, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: channel, - accountId, - patch: {}, - }); - }, -}; +export { zalouserSetupAdapter } from "./setup-core.js"; export const zalouserSetupWizard: ChannelSetupWizard = { channel, diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index 47fc787570c..3ad3ca47549 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -55,10 +55,8 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { formatAllowFromLowercase } from "./allow-from.js"; export { resolveSenderCommandAuthorization } from "./command-auth.js"; export { resolveChannelAccountConfigBasePath } from "./config-paths.js"; -export { - zalouserSetupAdapter, - zalouserSetupWizard, -} from "../../extensions/zalouser/src/setup-surface.js"; +export { zalouserSetupAdapter } from "../../extensions/zalouser/src/setup-core.js"; +export { zalouserSetupWizard } from "../../extensions/zalouser/src/setup-surface.js"; export { evaluateGroupRouteAccessForPolicy, resolveSenderScopedGroupPolicy, From fdfefcaa118168fede051de6ccf1f8f4ee5e919b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 19:43:42 -0700 Subject: [PATCH 143/943] Status: skip unused channel issue scan in JSON mode --- src/commands/status.scan.test.ts | 6 +++++- src/commands/status.scan.ts | 6 ++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 9d3399997bf..b94f1f0ece0 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({ readBestEffortConfig: vi.fn(), resolveCommandSecretRefsViaGateway: vi.fn(), buildChannelsTable: vi.fn(), + callGateway: vi.fn(), getUpdateCheckResult: vi.fn(), getAgentLocalStatuses: vi.fn(), getStatusSummary: vi.fn(), @@ -51,7 +52,7 @@ vi.mock("../infra/tailscale.js", () => ({ vi.mock("../gateway/call.js", () => ({ buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails, - callGateway: vi.fn(), + callGateway: mocks.callGateway, })); vi.mock("../gateway/probe.js", () => ({ @@ -245,6 +246,9 @@ describe("scanStatus", () => { await scanStatus({ json: true }, {} as never); expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "channels" }); + expect(mocks.callGateway).not.toHaveBeenCalledWith( + expect.objectContaining({ method: "channels.status" }), + ); }); it("preloads channel plugins for status --json when channel auth is env-only", async () => { diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 0de308f17f2..8de4aae7745 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -247,11 +247,9 @@ async function scanStatusJsonFast(opts: { const gatewaySelf = gatewayProbe?.presence ? pickGatewaySelfPresence(gatewayProbe.presence) : null; - const channelsStatusPromise = resolveChannelsStatus({ cfg, gatewayReachable, opts }); const memoryPlugin = resolveMemoryPluginStatus(cfg); const memoryPromise = resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }); - const [channelsStatus, memory] = await Promise.all([channelsStatusPromise, memoryPromise]); - const channelIssues = channelsStatus ? collectChannelStatusIssues(channelsStatus) : []; + const memory = await memoryPromise; return { cfg, @@ -270,7 +268,7 @@ async function scanStatusJsonFast(opts: { gatewayProbe, gatewayReachable, gatewaySelf, - channelIssues, + channelIssues: [], agentStatus, channels: { rows: [], details: [] }, summary, From a97e1e1611aa3dd6e4307198da4f6691b108f5d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 19:47:49 -0700 Subject: [PATCH 144/943] fix(plugins): tighten lazy setup typing --- extensions/slack/src/channel.setup.ts | 4 ++-- src/commands/onboard-channels.ts | 2 +- src/commands/onboarding/registry.ts | 18 ++++++++++++------ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/extensions/slack/src/channel.setup.ts b/extensions/slack/src/channel.setup.ts index 2f7b888ca18..83cd1625059 100644 --- a/extensions/slack/src/channel.setup.ts +++ b/extensions/slack/src/channel.setup.ts @@ -7,6 +7,7 @@ import { buildChannelConfigSchema, getChatChannelMeta, inspectSlackAccount, + isSlackInteractiveRepliesEnabled, listSlackAccountIds, resolveDefaultSlackAccountId, resolveSlackAccount, @@ -68,8 +69,7 @@ export const slackSetupPlugin: ChannelPlugin = { }, agentPrompt: { messageToolHints: ({ cfg, accountId }) => - cfg.channels?.slack?.accounts?.[accountId ?? "default"]?.capabilities?.interactiveReplies === - true || cfg.channels?.slack?.capabilities?.interactiveReplies === true + isSlackInteractiveRepliesEnabled({ cfg, accountId }) ? [ "- Slack interactive replies: use `[[slack_buttons: Label:value, Other:other]]` to add action buttons that route clicks back as Slack interaction system events.", "- Slack selects: use `[[slack_select: Placeholder | Label:value, Other:other]]` to add a static select menu that routes the chosen value back as a Slack interaction system event.", diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index cd269ac2cf9..c70fbde04ab 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -280,7 +280,7 @@ async function maybeConfigureDmPolicies(params: { resolveAdapter?: (channel: ChannelChoice) => ChannelOnboardingAdapter | undefined; }): Promise { const { selection, prompter, accountIdsByChannel } = params; - const resolve = params.resolveAdapter; + const resolve = params.resolveAdapter ?? (() => undefined); const dmPolicies = selection .map((channel) => resolve(channel)?.dmPolicy) .filter(Boolean) as ChannelOnboardingDmPolicy[]; diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts index 01bc0deeb7a..9d7711e3092 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/onboarding/registry.ts @@ -51,17 +51,23 @@ export async function loadBundledChannelOnboardingPlugin( ): Promise { switch (channel) { case "discord": - return (await import("../../../extensions/discord/setup-entry.js")).default.plugin; + return (await import("../../../extensions/discord/setup-entry.js")).default + .plugin as ChannelPlugin; case "imessage": - return (await import("../../../extensions/imessage/setup-entry.js")).default.plugin; + return (await import("../../../extensions/imessage/setup-entry.js")).default + .plugin as ChannelPlugin; case "signal": - return (await import("../../../extensions/signal/setup-entry.js")).default.plugin; + return (await import("../../../extensions/signal/setup-entry.js")).default + .plugin as ChannelPlugin; case "slack": - return (await import("../../../extensions/slack/setup-entry.js")).default.plugin; + return (await import("../../../extensions/slack/setup-entry.js")).default + .plugin as ChannelPlugin; case "telegram": - return (await import("../../../extensions/telegram/setup-entry.js")).default.plugin; + return (await import("../../../extensions/telegram/setup-entry.js")).default + .plugin as ChannelPlugin; case "whatsapp": - return (await import("../../../extensions/whatsapp/setup-entry.js")).default.plugin; + return (await import("../../../extensions/whatsapp/setup-entry.js")).default + .plugin as ChannelPlugin; default: return undefined; } From 65ec4843e8383aada4ae600f279ece89f945057f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 02:51:56 +0000 Subject: [PATCH 145/943] fix: tighten outbound channel/plugin resolution --- src/infra/outbound/channel-selection.test.ts | 38 +++++++++++++++++++ src/infra/outbound/channel-selection.ts | 40 +++++++++++++++++--- src/infra/outbound/message-action-runner.ts | 6 +++ 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/src/infra/outbound/channel-selection.test.ts b/src/infra/outbound/channel-selection.test.ts index da605dcdb63..9448b919312 100644 --- a/src/infra/outbound/channel-selection.test.ts +++ b/src/infra/outbound/channel-selection.test.ts @@ -2,12 +2,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ listChannelPlugins: vi.fn(), + resolveOutboundChannelPlugin: vi.fn(), })); vi.mock("../../channels/plugins/index.js", () => ({ listChannelPlugins: mocks.listChannelPlugins, })); +vi.mock("./channel-resolution.js", () => ({ + resolveOutboundChannelPlugin: mocks.resolveOutboundChannelPlugin, +})); + import { listConfiguredMessageChannels, resolveMessageChannelSelection, @@ -36,6 +41,10 @@ describe("listConfiguredMessageChannels", () => { beforeEach(() => { mocks.listChannelPlugins.mockReset(); mocks.listChannelPlugins.mockReturnValue([]); + mocks.resolveOutboundChannelPlugin.mockReset(); + mocks.resolveOutboundChannelPlugin.mockImplementation(({ channel }: { channel: string }) => ({ + id: channel, + })); }); it("skips unknown plugin ids and plugins without accounts", async () => { @@ -158,6 +167,35 @@ describe("resolveMessageChannelSelection", () => { ).rejects.toThrow("Unknown channel: channel:c123"); }); + it("falls back when the explicit known channel is unavailable in the active plugin registry", async () => { + mocks.resolveOutboundChannelPlugin.mockImplementation(({ channel }: { channel: string }) => + channel === "slack" ? { id: "slack" } : undefined, + ); + + const selection = await resolveMessageChannelSelection({ + cfg: {} as never, + channel: "discord", + fallbackChannel: "slack", + }); + + expect(selection).toEqual({ + channel: "slack", + configured: [], + source: "tool-context-fallback", + }); + }); + + it("throws unavailable when a known channel has no active plugin", async () => { + mocks.resolveOutboundChannelPlugin.mockReturnValue(undefined); + + await expect( + resolveMessageChannelSelection({ + cfg: {} as never, + channel: "discord", + }), + ).rejects.toThrow("Channel is unavailable: discord"); + }); + it("throws when no channel is provided and nothing is configured", async () => { await expect( resolveMessageChannelSelection({ diff --git a/src/infra/outbound/channel-selection.ts b/src/infra/outbound/channel-selection.ts index 9fbd592a589..024fc2273f6 100644 --- a/src/infra/outbound/channel-selection.ts +++ b/src/infra/outbound/channel-selection.ts @@ -7,6 +7,7 @@ import { isDeliverableMessageChannel, normalizeMessageChannel, } from "../../utils/message-channel.js"; +import { resolveOutboundChannelPlugin } from "./channel-resolution.js"; export type MessageChannelId = DeliverableMessageChannel; export type MessageChannelSelectionSource = @@ -34,6 +35,22 @@ function resolveKnownChannel(value?: string | null): MessageChannelId | undefine return normalized as MessageChannelId; } +function resolveAvailableKnownChannel(params: { + cfg: OpenClawConfig; + value?: string | null; +}): MessageChannelId | undefined { + const normalized = resolveKnownChannel(params.value); + if (!normalized) { + return undefined; + } + return resolveOutboundChannelPlugin({ + channel: normalized, + cfg: params.cfg, + }) + ? normalized + : undefined; +} + function isAccountEnabled(account: unknown): boolean { if (!account || typeof account !== "object") { return true; @@ -94,8 +111,15 @@ export async function resolveMessageChannelSelection(params: { }> { const normalized = normalizeMessageChannel(params.channel); if (normalized) { - if (!isKnownChannel(normalized)) { - const fallback = resolveKnownChannel(params.fallbackChannel); + const availableExplicit = resolveAvailableKnownChannel({ + cfg: params.cfg, + value: normalized, + }); + if (!availableExplicit) { + const fallback = resolveAvailableKnownChannel({ + cfg: params.cfg, + value: params.fallbackChannel, + }); if (fallback) { return { channel: fallback, @@ -103,16 +127,22 @@ export async function resolveMessageChannelSelection(params: { source: "tool-context-fallback", }; } - throw new Error(`Unknown channel: ${String(normalized)}`); + if (!isKnownChannel(normalized)) { + throw new Error(`Unknown channel: ${String(normalized)}`); + } + throw new Error(`Channel is unavailable: ${String(normalized)}`); } return { - channel: normalized as MessageChannelId, + channel: availableExplicit, configured: await listConfiguredMessageChannels(params.cfg), source: "explicit", }; } - const fallback = resolveKnownChannel(params.fallbackChannel); + const fallback = resolveAvailableKnownChannel({ + cfg: params.cfg, + value: params.fallbackChannel, + }); if (fallback) { return { channel: fallback, diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 0b6ad1ba16e..088baf75c22 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -20,6 +20,7 @@ import { buildChannelAccountBindings } from "../../routing/bindings.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import { type GatewayClientMode, type GatewayClientName } from "../../utils/message-channel.js"; import { throwIfAborted } from "./abort.js"; +import { resolveOutboundChannelPlugin } from "./channel-resolution.js"; import { listConfiguredMessageChannels, resolveMessageChannelSelection, @@ -670,6 +671,11 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise Date: Sun, 15 Mar 2026 19:53:51 -0700 Subject: [PATCH 146/943] fix(ci): repair security and route test fixtures --- extensions/mattermost/src/setup-surface.ts | 8 +++++++- src/cli/program/routes.test.ts | 7 +++++-- src/security/audit.test.ts | 9 ++++++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/extensions/mattermost/src/setup-surface.ts b/extensions/mattermost/src/setup-surface.ts index 2877541bba9..e1be50e662a 100644 --- a/extensions/mattermost/src/setup-surface.ts +++ b/extensions/mattermost/src/setup-surface.ts @@ -1,7 +1,13 @@ -import { DEFAULT_ACCOUNT_ID, hasConfiguredSecretInput } from "openclaw/plugin-sdk/mattermost"; +import { + DEFAULT_ACCOUNT_ID, + applySetupAccountConfigPatch, + hasConfiguredSecretInput, + type OpenClawConfig, +} from "openclaw/plugin-sdk/mattermost"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import { listMattermostAccountIds } from "./mattermost/accounts.js"; +import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; import { isMattermostConfigured, mattermostSetupAdapter, diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index e7958a684a5..0eb92333c0a 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -32,9 +32,12 @@ describe("program routes", () => { await expect(route?.run(argv)).resolves.toBe(false); } - it("matches status route and always preloads plugins", () => { + it("matches status route and preloads plugins only for text output", () => { const route = expectRoute(["status"]); - expect(route?.loadPlugins).toBe(true); + expect(typeof route?.loadPlugins).toBe("function"); + const shouldLoad = route?.loadPlugins as (argv: string[]) => boolean; + expect(shouldLoad(["node", "openclaw", "status"])).toBe(true); + expect(shouldLoad(["node", "openclaw", "status", "--json"])).toBe(false); }); it("matches health route and preloads plugins only for text output", () => { diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 84fcadf1f98..dd1040e1263 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1803,7 +1803,14 @@ description: test skill }); it("warns when multiple DM senders share the main session", async () => { - const cfg: OpenClawConfig = { session: { dmScope: "main" } }; + const cfg: OpenClawConfig = { + session: { dmScope: "main" }, + channels: { + whatsapp: { + enabled: true, + }, + }, + }; const plugins: ChannelPlugin[] = [ { id: "whatsapp", From a2cb81199e22d5425a1490736971b9a96cea3c2a Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:55:24 -0500 Subject: [PATCH 147/943] secrets: harden read-only SecretRef command paths and diagnostics (#47794) * secrets: harden read-only SecretRef resolution for status and audit * CLI: add SecretRef degrade-safe regression coverage * Docs: align SecretRef status and daemon probe semantics * Security audit: close SecretRef review gaps * Security audit: preserve source auth SecretRef configuredness * changelog Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com> --------- Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/cli/daemon.md | 4 +- docs/cli/doctor.md | 1 + docs/cli/gateway.md | 3 +- docs/cli/index.md | 1 + docs/cli/security.md | 8 + docs/cli/status.md | 1 + docs/gateway/secrets.md | 2 +- src/agents/tools/message-tool.ts | 13 +- src/cli/command-secret-gateway.test.ts | 33 ++- src/cli/command-secret-gateway.ts | 43 ++- src/cli/command-secret-targets.test.ts | 10 + src/cli/command-secret-targets.ts | 5 + src/cli/daemon-cli/status.gather.test.ts | 73 +++++- src/cli/daemon-cli/status.gather.ts | 39 ++- src/cli/daemon-cli/status.print.ts | 3 + src/cli/security-cli.test.ts | 245 ++++++++++++++++++ src/cli/security-cli.ts | 39 ++- src/commands/channel-account-context.test.ts | 67 +++++ src/commands/channel-account-context.ts | 146 ++++++++++- .../channels.status.command-flow.test.ts | 172 ++++++++++++ src/commands/channels/resolve.ts | 2 +- src/commands/channels/status.ts | 2 +- src/commands/doctor-config-flow.ts | 2 +- src/commands/doctor-security.test.ts | 26 ++ src/commands/doctor-security.ts | 10 +- ...rns-state-directory-is-missing.e2e.test.ts | 48 ++++ src/commands/gateway-status.test.ts | 34 ++- src/commands/gateway-status.ts | 2 +- src/commands/health.ts | 148 +++++++++-- src/commands/status-all.ts | 2 +- src/commands/status.link-channel.test.ts | 55 ++++ src/commands/status.link-channel.ts | 5 +- src/commands/status.scan.ts | 4 +- src/commands/status.test.ts | 5 + src/gateway/call.ts | 7 +- src/gateway/probe-auth.ts | 12 + src/security/audit-channel.ts | 84 +++++- src/security/audit.test.ts | 77 +++++- src/security/audit.ts | 37 ++- 40 files changed, 1368 insertions(+), 103 deletions(-) create mode 100644 src/cli/security-cli.test.ts create mode 100644 src/commands/channels.status.command-flow.test.ts create mode 100644 src/commands/status.link-channel.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f46e450d164..232cbb167a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized. - Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc. - Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus. +- secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. ### Fixes diff --git a/docs/cli/daemon.md b/docs/cli/daemon.md index 8f6042e7400..f21c3930ece 100644 --- a/docs/cli/daemon.md +++ b/docs/cli/daemon.md @@ -34,13 +34,15 @@ openclaw daemon uninstall ## Common options -- `status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--deep`, `--json` +- `status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json` - `install`: `--port`, `--runtime `, `--token`, `--force`, `--json` - lifecycle (`uninstall|start|stop|restart`): `--json` Notes: - `status` resolves configured auth SecretRefs for probe auth when possible. +- If a required auth SecretRef is unresolved in this command path, `daemon status --json` reports `rpc.authWarning` when probe connectivity/auth fails; pass `--token`/`--password` explicitly or resolve the secret source first. +- If the probe succeeds, unresolved auth-ref warnings are suppressed to avoid false positives. - On Linux systemd installs, `status` token-drift checks include both `Environment=` and `EnvironmentFile=` unit sources. - When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `install` validates that the SecretRef is resolvable but does not persist the resolved token into service environment metadata. - If token auth requires a token and the configured token SecretRef is unresolved, install fails closed. diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index 90e5fa7d7a2..4718135ee68 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -31,6 +31,7 @@ Notes: - Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime. - Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing. - If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`). +- If `gateway.auth.token`/`gateway.auth.password` are SecretRef-managed and unavailable in the current command path, doctor reports a read-only warning and does not write plaintext fallback credentials. ## macOS: `launchctl` env overrides diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 16b05baefce..d36fbde6c35 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -111,7 +111,8 @@ Options: Notes: - `gateway status` resolves configured auth SecretRefs for probe auth when possible. -- If a required auth SecretRef is unresolved in this command path, probe auth can fail; pass `--token`/`--password` explicitly or resolve the secret source first. +- If a required auth SecretRef is unresolved in this command path, `gateway status --json` reports `rpc.authWarning` when probe connectivity/auth fails; pass `--token`/`--password` explicitly or resolve the secret source first. +- If the probe succeeds, unresolved auth-ref warnings are suppressed to avoid false positives. - Use `--require-rpc` in scripts and automation when a listening service is not enough and you need the Gateway RPC itself to be healthy. - On Linux systemd installs, service auth drift checks read both `Environment=` and `EnvironmentFile=` values from the unit (including `%h`, quoted paths, multiple files, and optional `-` files). diff --git a/docs/cli/index.md b/docs/cli/index.md index fbc0bf1378f..f99b04efece 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -783,6 +783,7 @@ Notes: - `gateway status` supports `--no-probe`, `--deep`, `--require-rpc`, and `--json` for scripting. - `gateway status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named OpenClaw services are treated as first-class and aren't flagged as "extra". - `gateway status` prints which config path the CLI uses vs which config the service likely uses (service env), plus the resolved probe target URL. +- If gateway auth SecretRefs are unresolved in the current command path, `gateway status --json` reports `rpc.authWarning` only when probe connectivity/auth fails (warnings are suppressed when probe succeeds). - On Linux systemd installs, status token-drift checks include both `Environment=` and `EnvironmentFile=` unit sources. - `gateway install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly). - `gateway install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs). diff --git a/docs/cli/security.md b/docs/cli/security.md index cc705b31a30..76a7ae75976 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -19,6 +19,8 @@ Related: ```bash openclaw security audit openclaw security audit --deep +openclaw security audit --deep --password +openclaw security audit --deep --token openclaw security audit --fix openclaw security audit --json ``` @@ -40,6 +42,12 @@ It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable with Settings prefixed with `dangerous`/`dangerously` are explicit break-glass operator overrides; enabling one is not, by itself, a security vulnerability report. For the complete dangerous-parameter inventory, see the "Insecure or dangerous flags summary" section in [Security](/gateway/security). +SecretRef behavior: + +- `security audit` resolves supported SecretRefs in read-only mode for its targeted paths. +- If a SecretRef is unavailable in the current command path, audit continues and reports `secretDiagnostics` (instead of crashing). +- `--token` and `--password` only override deep-probe auth for that command invocation; they do not rewrite config or SecretRef mappings. + ## JSON output Use `--json` for CI/policy checks: diff --git a/docs/cli/status.md b/docs/cli/status.md index 856c341b036..770bf6ab50d 100644 --- a/docs/cli/status.md +++ b/docs/cli/status.md @@ -26,3 +26,4 @@ Notes: - Update info surfaces in the Overview; if an update is available, status prints a hint to run `openclaw update` (see [Updating](/install/updating)). - Read-only status surfaces (`status`, `status --json`, `status --all`) resolve supported SecretRefs for their targeted config paths when possible. - If a supported channel SecretRef is configured but unavailable in the current command path, status stays read-only and reports degraded output instead of crashing. Human output shows warnings such as “configured token unavailable in this command path”, and JSON output includes `secretDiagnostics`. +- When command-local SecretRef resolution succeeds, status prefers the resolved snapshot and clears transient “secret unavailable” channel markers from the final output. diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 93cd508d4f1..379e4a527d4 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -348,7 +348,7 @@ Command paths can opt into supported SecretRef resolution via gateway snapshot R There are two broad behaviors: - Strict command paths (for example `openclaw memory` remote-memory paths and `openclaw qr --remote`) read from the active snapshot and fail fast when a required SecretRef is unavailable. -- Read-only command paths (for example `openclaw status`, `openclaw status --all`, `openclaw channels status`, `openclaw channels resolve`, and read-only doctor/config repair flows) also prefer the active snapshot, but degrade instead of aborting when a targeted SecretRef is unavailable in that command path. +- Read-only command paths (for example `openclaw status`, `openclaw status --all`, `openclaw channels status`, `openclaw channels resolve`, `openclaw security audit`, and read-only doctor/config repair flows) also prefer the active snapshot, but degrade instead of aborting when a targeted SecretRef is unavailable in that command path. Read-only behavior: diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 63963ab5f38..b4ec54d62dd 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -12,6 +12,8 @@ import { CHANNEL_MESSAGE_ACTION_NAMES, type ChannelMessageActionName, } from "../../channels/plugins/types.js"; +import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js"; +import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js"; @@ -709,7 +711,16 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { } } - const cfg = options?.config ?? loadConfig(); + const cfg = options?.config + ? options.config + : ( + await resolveCommandSecretRefsViaGateway({ + config: loadConfig(), + commandName: "tools.message", + targetIds: getChannelsCommandSecretTargetIds(), + mode: "enforce_resolved", + }) + ).resolvedConfig; const action = readStringParam(params, "action", { required: true, }) as ChannelMessageActionName; diff --git a/src/cli/command-secret-gateway.test.ts b/src/cli/command-secret-gateway.test.ts index 74c47f637e9..c9de91d4257 100644 --- a/src/cli/command-secret-gateway.test.ts +++ b/src/cli/command-secret-gateway.test.ts @@ -43,7 +43,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { async function resolveTalkApiKey(params: { envKey: string; commandName?: string; - mode?: "strict" | "summary"; + mode?: "enforce_resolved" | "read_only_status"; }) { return resolveCommandSecretRefsViaGateway({ config: makeTalkApiKeySecretRefConfig(params.envKey), @@ -447,7 +447,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { expect(result.diagnostics).toEqual(["memory search ref inactive"]); }); - it("degrades unresolved refs in summary mode instead of throwing", async () => { + it("degrades unresolved refs in read-only status mode instead of throwing", async () => { const envKey = "TALK_API_KEY_SUMMARY_MISSING"; callGateway.mockResolvedValueOnce({ assignments: [], @@ -457,7 +457,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { const result = await resolveTalkApiKey({ envKey, commandName: "status", - mode: "summary", + mode: "read_only_status", }); expect(result.resolvedConfig.talk?.apiKey).toBeUndefined(); expect(result.hadUnresolvedTargets).toBe(true); @@ -470,6 +470,25 @@ describe("resolveCommandSecretRefsViaGateway", () => { }); }); + it("accepts legacy summary mode as a read-only alias", async () => { + const envKey = "TALK_API_KEY_LEGACY_SUMMARY_MISSING"; + callGateway.mockResolvedValueOnce({ + assignments: [], + diagnostics: [], + }); + await withEnvValue(envKey, undefined, async () => { + const result = await resolveCommandSecretRefsViaGateway({ + config: makeTalkApiKeySecretRefConfig(envKey), + commandName: "status", + targetIds: new Set(["talk.apiKey"]), + mode: "summary", + }); + expect(result.resolvedConfig.talk?.apiKey).toBeUndefined(); + expect(result.hadUnresolvedTargets).toBe(true); + expect(result.targetStatesByPath["talk.apiKey"]).toBe("unresolved"); + }); + }); + it("uses targeted local fallback after an incomplete gateway snapshot", async () => { const envKey = "TALK_API_KEY_PARTIAL_GATEWAY"; callGateway.mockResolvedValueOnce({ @@ -480,7 +499,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { const result = await resolveTalkApiKey({ envKey, commandName: "status", - mode: "summary", + mode: "read_only_status", }); expect(result.resolvedConfig.talk?.apiKey).toBe("recovered-locally"); expect(result.hadUnresolvedTargets).toBe(false); @@ -571,7 +590,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { } as OpenClawConfig, commandName: "status", targetIds: new Set(["talk.apiKey"]), - mode: "summary", + mode: "read_only_status", }); expect(result.resolvedConfig.talk?.apiKey).toBe("target-only"); @@ -591,7 +610,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { } }); - it("degrades unresolved refs in operational read-only mode", async () => { + it("degrades unresolved refs in read-only operational mode", async () => { const envKey = "TALK_API_KEY_OPERATIONAL_MISSING"; const priorValue = process.env[envKey]; delete process.env[envKey]; @@ -606,7 +625,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { } as OpenClawConfig, commandName: "channels resolve", targetIds: new Set(["talk.apiKey"]), - mode: "operational_readonly", + mode: "read_only_operational", }); expect(result.resolvedConfig.talk?.apiKey).toBeUndefined(); diff --git a/src/cli/command-secret-gateway.ts b/src/cli/command-secret-gateway.ts index 03e578b642c..8b2b73c9f0f 100644 --- a/src/cli/command-secret-gateway.ts +++ b/src/cli/command-secret-gateway.ts @@ -26,7 +26,16 @@ type ResolveCommandSecretsResult = { hadUnresolvedTargets: boolean; }; -export type CommandSecretResolutionMode = "strict" | "summary" | "operational_readonly"; // pragma: allowlist secret +export type CommandSecretResolutionMode = + | "enforce_resolved" + | "read_only_status" + | "read_only_operational"; + +type LegacyCommandSecretResolutionMode = "strict" | "summary" | "operational_readonly"; // pragma: allowlist secret + +type CommandSecretResolutionModeInput = + | CommandSecretResolutionMode + | LegacyCommandSecretResolutionMode; export type CommandSecretTargetState = | "resolved_gateway" @@ -54,6 +63,22 @@ const WEB_RUNTIME_SECRET_PATH_PREFIXES = [ "tools.web.fetch.firecrawl.", ] as const; +function normalizeCommandSecretResolutionMode( + mode?: CommandSecretResolutionModeInput, +): CommandSecretResolutionMode { + if (!mode || mode === "enforce_resolved" || mode === "strict") { + return "enforce_resolved"; + } + if (mode === "read_only_status" || mode === "summary") { + return "read_only_status"; + } + return "read_only_operational"; +} + +function enforcesResolvedSecrets(mode: CommandSecretResolutionMode): boolean { + return mode === "enforce_resolved"; +} + function dedupeDiagnostics(entries: readonly string[]): string[] { const seen = new Set(); const ordered: string[] = []; @@ -242,7 +267,7 @@ async function resolveCommandSecretRefsLocally(params: { context, }); } catch (error) { - if (params.mode === "strict") { + if (enforcesResolvedSecrets(params.mode)) { throw error; } localResolutionDiagnostics.push( @@ -289,7 +314,7 @@ async function resolveCommandSecretRefsLocally(params: { analyzed, resolvedState: "resolved_local", }); - if (params.mode !== "strict" && analyzed.unresolved.length > 0) { + if (!enforcesResolvedSecrets(params.mode) && analyzed.unresolved.length > 0) { scrubUnresolvedAssignments(resolvedConfig, analyzed.unresolved); } else if (analyzed.unresolved.length > 0) { throw new Error( @@ -336,7 +361,7 @@ function buildUnresolvedDiagnostics( unresolved: UnresolvedCommandSecretAssignment[], mode: CommandSecretResolutionMode, ): string[] { - if (mode === "strict") { + if (enforcesResolvedSecrets(mode)) { return []; } return unresolved.map( @@ -411,7 +436,7 @@ async function resolveTargetSecretLocally(params: { }); setPathExistingStrict(params.resolvedConfig, params.target.pathSegments, resolved); } catch (error) { - if (params.mode !== "strict") { + if (!enforcesResolvedSecrets(params.mode)) { params.localResolutionDiagnostics.push( `${params.commandName}: failed to resolve ${params.target.path} locally (${describeUnknownError(error)}).`, ); @@ -423,9 +448,9 @@ export async function resolveCommandSecretRefsViaGateway(params: { config: OpenClawConfig; commandName: string; targetIds: Set; - mode?: CommandSecretResolutionMode; + mode?: CommandSecretResolutionModeInput; }): Promise { - const mode = params.mode ?? "strict"; + const mode = normalizeCommandSecretResolutionMode(params.mode); const configuredTargetRefPaths = collectConfiguredTargetRefPaths({ config: params.config, targetIds: params.targetIds, @@ -567,7 +592,7 @@ export async function resolveCommandSecretRefsViaGateway(params: { (entry) => !recoveredPaths.has(entry.path), ); if (stillUnresolved.length > 0) { - if (mode === "strict") { + if (enforcesResolvedSecrets(mode)) { throw new Error( `${params.commandName}: ${stillUnresolved[0]?.path ?? "target"} is unresolved in the active runtime snapshot.`, ); @@ -590,7 +615,7 @@ export async function resolveCommandSecretRefsViaGateway(params: { ]); } } catch (error) { - if (mode === "strict") { + if (enforcesResolvedSecrets(mode)) { throw error; } scrubUnresolvedAssignments(resolvedConfig, analyzed.unresolved); diff --git a/src/cli/command-secret-targets.test.ts b/src/cli/command-secret-targets.test.ts index a71ac5e00c4..22a23b36055 100644 --- a/src/cli/command-secret-targets.test.ts +++ b/src/cli/command-secret-targets.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { getAgentRuntimeCommandSecretTargetIds, getMemoryCommandSecretTargetIds, + getSecurityAuditCommandSecretTargetIds, } from "./command-secret-targets.js"; describe("command secret target ids", () => { @@ -21,4 +22,13 @@ describe("command secret target ids", () => { ]), ); }); + + it("includes gateway auth and channel targets for security audit", () => { + const ids = getSecurityAuditCommandSecretTargetIds(); + expect(ids.has("channels.discord.token")).toBe(true); + expect(ids.has("gateway.auth.token")).toBe(true); + expect(ids.has("gateway.auth.password")).toBe(true); + expect(ids.has("gateway.remote.token")).toBe(true); + expect(ids.has("gateway.remote.password")).toBe(true); + }); }); diff --git a/src/cli/command-secret-targets.ts b/src/cli/command-secret-targets.ts index e1c2c49e0ae..d6dde83cd19 100644 --- a/src/cli/command-secret-targets.ts +++ b/src/cli/command-secret-targets.ts @@ -30,6 +30,7 @@ const COMMAND_SECRET_TARGETS = { "agents.defaults.memorySearch.remote.", "agents.list[].memorySearch.remote.", ]), + securityAudit: idsByPrefix(["channels.", "gateway.auth.", "gateway.remote."]), } as const; function toTargetIdSet(values: readonly string[]): Set { @@ -59,3 +60,7 @@ export function getAgentRuntimeCommandSecretTargetIds(): Set { export function getStatusCommandSecretTargetIds(): Set { return toTargetIdSet(COMMAND_SECRET_TARGETS.status); } + +export function getSecurityAuditCommandSecretTargetIds(): Set { + return toTargetIdSet(COMMAND_SECRET_TARGETS.securityAudit); +} diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index 27b53753eda..fd94acca3a9 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -2,7 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { captureEnv } from "../../test-utils/env.js"; import type { GatewayRestartSnapshot } from "./restart-health.js"; -const callGatewayStatusProbe = vi.fn(async (_opts?: unknown) => ({ ok: true as const })); +const callGatewayStatusProbe = vi.fn< + (opts?: unknown) => Promise<{ ok: boolean; url?: string; error?: string | null }> +>(async (_opts?: unknown) => ({ + ok: true, + url: "ws://127.0.0.1:19001", + error: null, +})); const loadGatewayTlsRuntime = vi.fn(async (_cfg?: unknown) => ({ enabled: true, required: true, @@ -333,6 +339,71 @@ describe("gatherDaemonStatus", () => { ); }); + it("degrades safely when daemon probe auth SecretRef is unresolved", async () => { + daemonLoadedConfig = { + gateway: { + bind: "lan", + tls: { enabled: true }, + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_DAEMON_GATEWAY_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + + const status = await gatherDaemonStatus({ + rpc: {}, + probe: true, + deep: false, + }); + + expect(callGatewayStatusProbe).toHaveBeenCalledWith( + expect.objectContaining({ + token: undefined, + password: undefined, + }), + ); + expect(status.rpc?.authWarning).toBeUndefined(); + }); + + it("surfaces authWarning when daemon probe auth SecretRef is unresolved and probe fails", async () => { + daemonLoadedConfig = { + gateway: { + bind: "lan", + tls: { enabled: true }, + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_DAEMON_GATEWAY_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + callGatewayStatusProbe.mockResolvedValueOnce({ + ok: false, + error: "gateway closed", + url: "wss://127.0.0.1:19001", + }); + + const status = await gatherDaemonStatus({ + rpc: {}, + probe: true, + deep: false, + }); + + expect(status.rpc?.ok).toBe(false); + expect(status.rpc?.authWarning).toContain("gateway.auth.token SecretRef is unavailable"); + expect(status.rpc?.authWarning).toContain("probing without configured auth credentials"); + }); + it("keeps remote probe auth strict when remote token is missing", async () => { daemonLoadedConfig = { gateway: { diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 707a908b1f6..4647b789ff9 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -16,7 +16,7 @@ import type { ServiceConfigAudit } from "../../daemon/service-audit.js"; import { auditGatewayServiceConfig } from "../../daemon/service-audit.js"; import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js"; import { resolveGatewayService } from "../../daemon/service.js"; -import { trimToUndefined } from "../../gateway/credentials.js"; +import { isGatewaySecretRefUnavailableError, trimToUndefined } from "../../gateway/credentials.js"; import { resolveGatewayBindHost } from "../../gateway/net.js"; import { resolveGatewayProbeAuthWithSecretInputs } from "../../gateway/probe-auth.js"; import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js"; @@ -112,6 +112,7 @@ export type DaemonStatus = { ok: boolean; error?: string; url?: string; + authWarning?: string; }; health?: { healthy: boolean; @@ -130,6 +131,10 @@ function shouldReportPortUsage(status: PortUsageStatus | undefined, rpcOk?: bool return true; } +function parseGatewaySecretRefPathFromError(error: unknown): string | null { + return isGatewaySecretRefUnavailableError(error) ? error.path : null; +} + async function loadDaemonConfigContext( serviceEnv?: Record, ): Promise { @@ -310,8 +315,11 @@ export async function gatherDaemonStatus( const tlsRuntime = shouldUseLocalTlsRuntime ? await loadGatewayTlsRuntime(daemonCfg.gateway?.tls) : undefined; - const daemonProbeAuth = opts.probe - ? await resolveGatewayProbeAuthWithSecretInputs({ + let daemonProbeAuth: { token?: string; password?: string } | undefined; + let rpcAuthWarning: string | undefined; + if (opts.probe) { + try { + daemonProbeAuth = await resolveGatewayProbeAuthWithSecretInputs({ cfg: daemonCfg, mode: daemonCfg.gateway?.mode === "remote" ? "remote" : "local", env: mergedDaemonEnv as NodeJS.ProcessEnv, @@ -319,8 +327,16 @@ export async function gatherDaemonStatus( token: opts.rpc.token, password: opts.rpc.password, }, - }) - : undefined; + }); + } catch (error) { + const refPath = parseGatewaySecretRefPathFromError(error); + if (!refPath) { + throw error; + } + daemonProbeAuth = undefined; + rpcAuthWarning = `${refPath} SecretRef is unavailable in this command path; probing without configured auth credentials.`; + } + } const rpc = opts.probe ? await probeGatewayStatus({ @@ -336,6 +352,9 @@ export async function gatherDaemonStatus( configPath: daemonConfigSummary.path, }) : undefined; + if (rpc?.ok) { + rpcAuthWarning = undefined; + } const health = opts.probe && loaded ? await inspectGatewayRestart({ @@ -369,7 +388,15 @@ export async function gatherDaemonStatus( port: portStatus, ...(portCliStatus ? { portCli: portCliStatus } : {}), lastError, - ...(rpc ? { rpc: { ...rpc, url: gateway.probeUrl } } : {}), + ...(rpc + ? { + rpc: { + ...rpc, + url: gateway.probeUrl, + ...(rpcAuthWarning ? { authWarning: rpcAuthWarning } : {}), + }, + } + : {}), ...(health ? { health: { diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index 91348d10d4a..088a3654797 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -181,6 +181,9 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) defaultRuntime.log(`${label("RPC probe:")} ${okText("ok")}`); } else { defaultRuntime.error(`${label("RPC probe:")} ${errorText("failed")}`); + if (rpc.authWarning) { + defaultRuntime.error(`${label("RPC auth:")} ${warnText(rpc.authWarning)}`); + } if (rpc.url) { defaultRuntime.error(`${label("RPC target:")} ${rpc.url}`); } diff --git a/src/cli/security-cli.test.ts b/src/cli/security-cli.test.ts new file mode 100644 index 00000000000..95c3e62d4ae --- /dev/null +++ b/src/cli/security-cli.test.ts @@ -0,0 +1,245 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createCliRuntimeCapture } from "./test-runtime-capture.js"; + +const loadConfig = vi.fn(); +const runSecurityAudit = vi.fn(); +const fixSecurityFootguns = vi.fn(); +const resolveCommandSecretRefsViaGateway = vi.fn(); +const getSecurityAuditCommandSecretTargetIds = vi.fn( + () => new Set(["gateway.auth.token", "gateway.auth.password"]), +); + +const { defaultRuntime, runtimeLogs, resetRuntimeCapture } = createCliRuntimeCapture(); + +vi.mock("../config/config.js", () => ({ + loadConfig: () => loadConfig(), +})); + +vi.mock("../runtime.js", () => ({ + defaultRuntime, +})); + +vi.mock("../security/audit.js", () => ({ + runSecurityAudit: (opts: unknown) => runSecurityAudit(opts), +})); + +vi.mock("../security/fix.js", () => ({ + fixSecurityFootguns: () => fixSecurityFootguns(), +})); + +vi.mock("./command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway: (opts: unknown) => resolveCommandSecretRefsViaGateway(opts), +})); + +vi.mock("./command-secret-targets.js", () => ({ + getSecurityAuditCommandSecretTargetIds: () => getSecurityAuditCommandSecretTargetIds(), +})); + +const { registerSecurityCli } = await import("./security-cli.js"); + +function createProgram() { + const program = new Command(); + program.exitOverride(); + registerSecurityCli(program); + return program; +} + +describe("security CLI", () => { + beforeEach(() => { + resetRuntimeCapture(); + loadConfig.mockReset(); + runSecurityAudit.mockReset(); + fixSecurityFootguns.mockReset(); + resolveCommandSecretRefsViaGateway.mockReset(); + getSecurityAuditCommandSecretTargetIds.mockClear(); + fixSecurityFootguns.mockResolvedValue({ + changes: [], + actions: [], + errors: [], + }); + }); + + it("runs audit with read-only SecretRef resolution and prints JSON diagnostics", async () => { + const sourceConfig = { + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + const resolvedConfig = { + ...sourceConfig, + gateway: { + ...sourceConfig.gateway, + auth: { + ...sourceConfig.gateway.auth, + token: "resolved-token", + }, + }, + }; + loadConfig.mockReturnValue(sourceConfig); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig, + diagnostics: [ + "security audit: gateway secrets.resolve unavailable (gateway closed); resolved command secrets locally.", + ], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + runSecurityAudit.mockResolvedValue({ + ts: 0, + summary: { critical: 0, warn: 1, info: 0 }, + findings: [ + { + checkId: "gateway.probe_failed", + severity: "warn", + title: "Gateway probe failed (deep)", + detail: "connect failed: connect ECONNREFUSED 127.0.0.1:18789", + }, + ], + }); + + await createProgram().parseAsync(["security", "audit", "--json"], { from: "user" }); + + expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( + expect.objectContaining({ + config: sourceConfig, + commandName: "security audit", + mode: "read_only_status", + targetIds: expect.any(Set), + }), + ); + expect(runSecurityAudit).toHaveBeenCalledWith( + expect.objectContaining({ + config: resolvedConfig, + sourceConfig, + deep: false, + includeFilesystem: true, + includeChannelSecurity: true, + }), + ); + const payload = JSON.parse(String(runtimeLogs.at(-1))); + expect(payload.secretDiagnostics).toEqual([ + "security audit: gateway secrets.resolve unavailable (gateway closed); resolved command secrets locally.", + ]); + }); + + it("forwards --token to deep probe auth without altering command-level resolver mode", async () => { + const sourceConfig = { gateway: { mode: "local" } }; + loadConfig.mockReturnValue(sourceConfig); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: sourceConfig, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + runSecurityAudit.mockResolvedValue({ + ts: 0, + summary: { critical: 0, warn: 0, info: 0 }, + findings: [], + }); + + await createProgram().parseAsync( + ["security", "audit", "--deep", "--token", "explicit-token", "--json"], + { + from: "user", + }, + ); + + expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "read_only_status", + }), + ); + expect(runSecurityAudit).toHaveBeenCalledWith( + expect.objectContaining({ + deep: true, + deepProbeAuth: { token: "explicit-token" }, + }), + ); + }); + + it("forwards --password to deep probe auth without altering command-level resolver mode", async () => { + const sourceConfig = { gateway: { mode: "local" } }; + loadConfig.mockReturnValue(sourceConfig); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: sourceConfig, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + runSecurityAudit.mockResolvedValue({ + ts: 0, + summary: { critical: 0, warn: 0, info: 0 }, + findings: [], + }); + + await createProgram().parseAsync( + ["security", "audit", "--deep", "--password", "explicit-password", "--json"], + { + from: "user", + }, + ); + + expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "read_only_status", + }), + ); + expect(runSecurityAudit).toHaveBeenCalledWith( + expect.objectContaining({ + deep: true, + deepProbeAuth: { password: "explicit-password" }, + }), + ); + }); + + it("forwards both --token and --password to deep probe auth", async () => { + const sourceConfig = { gateway: { mode: "local" } }; + loadConfig.mockReturnValue(sourceConfig); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: sourceConfig, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + runSecurityAudit.mockResolvedValue({ + ts: 0, + summary: { critical: 0, warn: 0, info: 0 }, + findings: [], + }); + + await createProgram().parseAsync( + [ + "security", + "audit", + "--deep", + "--token", + "explicit-token", + "--password", + "explicit-password", + "--json", + ], + { + from: "user", + }, + ); + + expect(runSecurityAudit).toHaveBeenCalledWith( + expect.objectContaining({ + deep: true, + deepProbeAuth: { + token: "explicit-token", + password: "explicit-password", + }, + }), + ); + }); +}); diff --git a/src/cli/security-cli.ts b/src/cli/security-cli.ts index f55f657f4c1..586e5e0f114 100644 --- a/src/cli/security-cli.ts +++ b/src/cli/security-cli.ts @@ -7,12 +7,16 @@ import { formatDocsLink } from "../terminal/links.js"; import { isRich, theme } from "../terminal/theme.js"; import { shortenHomeInString, shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; +import { resolveCommandSecretRefsViaGateway } from "./command-secret-gateway.js"; +import { getSecurityAuditCommandSecretTargetIds } from "./command-secret-targets.js"; import { formatHelpExamples } from "./help-format.js"; type SecurityAuditOptions = { json?: boolean; deep?: boolean; fix?: boolean; + token?: string; + password?: string; }; function formatSummary(summary: { critical: number; warn: number; info: number }): string { @@ -37,6 +41,11 @@ export function registerSecurityCli(program: Command) { `\n${theme.heading("Examples:")}\n${formatHelpExamples([ ["openclaw security audit", "Run a local security audit."], ["openclaw security audit --deep", "Include best-effort live Gateway probe checks."], + ["openclaw security audit --deep --token ", "Use explicit token for deep probe."], + [ + "openclaw security audit --deep --password ", + "Use explicit password for deep probe.", + ], ["openclaw security audit --fix", "Apply safe remediations and file-permission fixes."], ["openclaw security audit --json", "Output machine-readable JSON."], ])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/security", "docs.openclaw.ai/cli/security")}\n`, @@ -46,22 +55,45 @@ export function registerSecurityCli(program: Command) { .command("audit") .description("Audit config + local state for common security foot-guns") .option("--deep", "Attempt live Gateway probe (best-effort)", false) + .option("--token ", "Use explicit gateway token for deep probe auth") + .option("--password ", "Use explicit gateway password for deep probe auth") .option("--fix", "Apply safe fixes (tighten defaults + chmod state/config)", false) .option("--json", "Print JSON", false) .action(async (opts: SecurityAuditOptions) => { const fixResult = opts.fix ? await fixSecurityFootguns().catch((_err) => null) : null; - const cfg = loadConfig(); + const sourceConfig = loadConfig(); + const { resolvedConfig: cfg, diagnostics: secretDiagnostics } = + await resolveCommandSecretRefsViaGateway({ + config: sourceConfig, + commandName: "security audit", + targetIds: getSecurityAuditCommandSecretTargetIds(), + mode: "read_only_status", + }); const report = await runSecurityAudit({ config: cfg, + sourceConfig, deep: Boolean(opts.deep), includeFilesystem: true, includeChannelSecurity: true, + deepProbeAuth: + opts.token?.trim() || opts.password?.trim() + ? { + ...(opts.token?.trim() ? { token: opts.token } : {}), + ...(opts.password?.trim() ? { password: opts.password } : {}), + } + : undefined, }); if (opts.json) { defaultRuntime.log( - JSON.stringify(fixResult ? { fix: fixResult, report } : report, null, 2), + JSON.stringify( + fixResult + ? { fix: fixResult, report, secretDiagnostics } + : { ...report, secretDiagnostics }, + null, + 2, + ), ); return; } @@ -74,6 +106,9 @@ export function registerSecurityCli(program: Command) { lines.push(heading("OpenClaw security audit")); lines.push(muted(`Summary: ${formatSummary(report.summary)}`)); lines.push(muted(`Run deeper: ${formatCliCommand("openclaw security audit --deep")}`)); + for (const diagnostic of secretDiagnostics) { + lines.push(muted(`[secrets] ${diagnostic}`)); + } if (opts.fix) { lines.push(muted(`Fix: ${formatCliCommand("openclaw security audit --fix")}`)); diff --git a/src/commands/channel-account-context.test.ts b/src/commands/channel-account-context.test.ts index 9fdaadb5231..4cdbde4d7e2 100644 --- a/src/commands/channel-account-context.test.ts +++ b/src/commands/channel-account-context.test.ts @@ -21,6 +21,8 @@ describe("resolveDefaultChannelAccountContext", () => { expect(result.account).toBe(account); expect(result.enabled).toBe(true); expect(result.configured).toBe(true); + expect(result.diagnostics).toEqual([]); + expect(result.degraded).toBe(false); }); it("uses plugin enable/configure hooks", async () => { @@ -43,5 +45,70 @@ describe("resolveDefaultChannelAccountContext", () => { expect(isConfigured).toHaveBeenCalledWith(account, {}); expect(result.enabled).toBe(false); expect(result.configured).toBe(false); + expect(result.diagnostics).toEqual([]); + expect(result.degraded).toBe(false); + }); + + it("keeps strict mode fail-closed when resolveAccount throws", async () => { + const plugin = { + id: "demo", + config: { + listAccountIds: () => ["acc-err"], + resolveAccount: () => { + throw new Error("missing secret"); + }, + }, + } as unknown as ChannelPlugin; + + await expect(resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig)).rejects.toThrow( + /missing secret/i, + ); + }); + + it("degrades safely in read_only mode when resolveAccount throws", async () => { + const plugin = { + id: "demo", + config: { + listAccountIds: () => ["acc-err"], + resolveAccount: () => { + throw new Error("missing secret"); + }, + }, + } as unknown as ChannelPlugin; + + const result = await resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig, { + mode: "read_only", + commandName: "status", + }); + + expect(result.enabled).toBe(false); + expect(result.configured).toBe(false); + expect(result.degraded).toBe(true); + expect(result.diagnostics.some((entry) => entry.includes("failed to resolve account"))).toBe( + true, + ); + }); + + it("prefers inspectAccount in read_only mode", async () => { + const inspectAccount = vi.fn(() => ({ configured: true, enabled: true })); + const resolveAccount = vi.fn(() => ({ configured: false, enabled: false })); + const plugin = { + id: "demo", + config: { + listAccountIds: () => ["acc-1"], + inspectAccount, + resolveAccount, + }, + } as unknown as ChannelPlugin; + + const result = await resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig, { + mode: "read_only", + }); + + expect(inspectAccount).toHaveBeenCalled(); + expect(resolveAccount).not.toHaveBeenCalled(); + expect(result.enabled).toBe(true); + expect(result.configured).toBe(true); + expect(result.degraded).toBe(true); }); }); diff --git a/src/commands/channel-account-context.ts b/src/commands/channel-account-context.ts index 36ce8c53e72..c997ec3e18a 100644 --- a/src/commands/channel-account-context.ts +++ b/src/commands/channel-account-context.ts @@ -1,6 +1,8 @@ import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; +import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; import type { OpenClawConfig } from "../config/config.js"; +import { formatErrorMessage } from "../infra/errors.js"; export type ChannelDefaultAccountContext = { accountIds: string[]; @@ -8,22 +10,154 @@ export type ChannelDefaultAccountContext = { account: unknown; enabled: boolean; configured: boolean; + diagnostics: string[]; + /** + * Indicates read-only resolution was used instead of strict full-account resolution. + * This is expected for read_only mode and does not necessarily mean an error occurred. + */ + degraded: boolean; }; +export type ChannelAccountContextMode = "strict" | "read_only"; + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function getBooleanField(value: unknown, key: string): boolean | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + return typeof record[key] === "boolean" ? record[key] : undefined; +} + +function formatContextDiagnostic(params: { + commandName?: string; + pluginId: string; + accountId: string; + message: string; +}): string { + const prefix = params.commandName ? `${params.commandName}: ` : ""; + return `${prefix}channels.${params.pluginId}.accounts.${params.accountId}: ${params.message}`; +} + export async function resolveDefaultChannelAccountContext( plugin: ChannelPlugin, cfg: OpenClawConfig, + options?: { mode?: ChannelAccountContextMode; commandName?: string }, ): Promise { + const mode = options?.mode ?? "strict"; const accountIds = plugin.config.listAccountIds(cfg); const defaultAccountId = resolveChannelDefaultAccountId({ plugin, cfg, accountIds, }); - const account = plugin.config.resolveAccount(cfg, defaultAccountId); - const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : true; - const configured = plugin.config.isConfigured - ? await plugin.config.isConfigured(account, cfg) - : true; - return { accountIds, defaultAccountId, account, enabled, configured }; + if (mode === "strict") { + const account = plugin.config.resolveAccount(cfg, defaultAccountId); + const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : true; + const configured = plugin.config.isConfigured + ? await plugin.config.isConfigured(account, cfg) + : true; + return { + accountIds, + defaultAccountId, + account, + enabled, + configured, + diagnostics: [], + degraded: false, + }; + } + + const diagnostics: string[] = []; + let degraded = false; + + const inspected = + plugin.config.inspectAccount?.(cfg, defaultAccountId) ?? + inspectReadOnlyChannelAccount({ + channelId: plugin.id, + cfg, + accountId: defaultAccountId, + }); + + let account = inspected; + if (!account) { + try { + account = plugin.config.resolveAccount(cfg, defaultAccountId); + } catch (error) { + degraded = true; + diagnostics.push( + formatContextDiagnostic({ + commandName: options?.commandName, + pluginId: plugin.id, + accountId: defaultAccountId, + message: `failed to resolve account (${formatErrorMessage(error)}); skipping read-only checks.`, + }), + ); + return { + accountIds, + defaultAccountId, + account: {}, + enabled: false, + configured: false, + diagnostics, + degraded, + }; + } + } else { + degraded = true; + } + + const inspectEnabled = getBooleanField(account, "enabled"); + let enabled = inspectEnabled ?? true; + if (inspectEnabled === undefined && plugin.config.isEnabled) { + try { + enabled = plugin.config.isEnabled(account, cfg); + } catch (error) { + degraded = true; + enabled = false; + diagnostics.push( + formatContextDiagnostic({ + commandName: options?.commandName, + pluginId: plugin.id, + accountId: defaultAccountId, + message: `failed to evaluate enabled state (${formatErrorMessage(error)}); treating as disabled.`, + }), + ); + } + } + + const inspectConfigured = getBooleanField(account, "configured"); + let configured = inspectConfigured ?? true; + if (inspectConfigured === undefined && plugin.config.isConfigured) { + try { + configured = await plugin.config.isConfigured(account, cfg); + } catch (error) { + degraded = true; + configured = false; + diagnostics.push( + formatContextDiagnostic({ + commandName: options?.commandName, + pluginId: plugin.id, + accountId: defaultAccountId, + message: `failed to evaluate configured state (${formatErrorMessage(error)}); treating as unconfigured.`, + }), + ); + } + } + + return { + accountIds, + defaultAccountId, + account, + enabled, + configured, + diagnostics, + degraded, + }; } diff --git a/src/commands/channels.status.command-flow.test.ts b/src/commands/channels.status.command-flow.test.ts new file mode 100644 index 00000000000..e613c64323a --- /dev/null +++ b/src/commands/channels.status.command-flow.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const callGateway = vi.fn(); +const resolveCommandSecretRefsViaGateway = vi.fn(); +const requireValidConfigSnapshot = vi.fn(); +const listChannelPlugins = vi.fn(); +const withProgress = vi.fn(async (_opts: unknown, run: () => Promise) => await run()); + +vi.mock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGateway(opts), +})); + +vi.mock("../cli/command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway: (opts: unknown) => resolveCommandSecretRefsViaGateway(opts), +})); + +vi.mock("./shared.js", () => ({ + requireValidConfigSnapshot: (runtime: unknown) => requireValidConfigSnapshot(runtime), + formatChannelAccountLabel: ({ + channel, + accountId, + }: { + channel: string; + accountId: string; + name?: string; + }) => `${channel} ${accountId}`, +})); + +vi.mock("../channels/plugins/index.js", () => ({ + listChannelPlugins: () => listChannelPlugins(), + getChannelPlugin: (channel: string) => + (listChannelPlugins() as Array<{ id: string }>).find((plugin) => plugin.id === channel), +})); + +vi.mock("../cli/progress.js", () => ({ + withProgress: (opts: unknown, run: () => Promise) => withProgress(opts, run), +})); + +const { channelsStatusCommand } = await import("./channels/status.js"); + +function createTokenOnlyPlugin() { + return { + id: "discord", + meta: { + id: "discord", + label: "Discord", + selectionLabel: "Discord", + docsPath: "/channels/discord", + blurb: "test", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + defaultAccountId: () => "default", + inspectAccount: (cfg: { secretResolved?: boolean }) => + cfg.secretResolved + ? { + name: "Primary", + enabled: true, + configured: true, + token: "resolved-discord-token", + tokenSource: "config", + tokenStatus: "available", + } + : { + name: "Primary", + enabled: true, + configured: true, + token: "", + tokenSource: "config", + tokenStatus: "configured_unavailable", + }, + resolveAccount: (cfg: { secretResolved?: boolean }) => + cfg.secretResolved + ? { + name: "Primary", + enabled: true, + configured: true, + token: "resolved-discord-token", + tokenSource: "config", + tokenStatus: "available", + } + : { + name: "Primary", + enabled: true, + configured: true, + token: "", + tokenSource: "config", + tokenStatus: "configured_unavailable", + }, + isConfigured: () => true, + isEnabled: () => true, + }, + actions: { + listActions: () => ["send"], + }, + }; +} + +function createRuntimeCapture() { + const logs: string[] = []; + const errors: string[] = []; + const runtime = { + log: (message: unknown) => logs.push(String(message)), + error: (message: unknown) => errors.push(String(message)), + exit: (_code?: number) => undefined, + }; + return { runtime, logs, errors }; +} + +describe("channelsStatusCommand SecretRef fallback flow", () => { + beforeEach(() => { + callGateway.mockReset(); + resolveCommandSecretRefsViaGateway.mockReset(); + requireValidConfigSnapshot.mockReset(); + listChannelPlugins.mockReset(); + withProgress.mockClear(); + listChannelPlugins.mockReturnValue([createTokenOnlyPlugin()]); + }); + + it("keeps read-only fallback output when SecretRefs are unresolved", async () => { + callGateway.mockRejectedValue(new Error("gateway closed")); + requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} }); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { secretResolved: false, channels: {} }, + diagnostics: [ + "channels status: channels.discord.token is unavailable in this command path; continuing with degraded read-only config.", + ], + targetStatesByPath: {}, + hadUnresolvedTargets: true, + }); + const { runtime, logs, errors } = createRuntimeCapture(); + + await channelsStatusCommand({ probe: false }, runtime as never); + + expect(errors.some((line) => line.includes("Gateway not reachable"))).toBe(true); + expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( + expect.objectContaining({ + commandName: "channels status", + mode: "read_only_status", + }), + ); + expect( + logs.some((line) => + line.includes("[secrets] channels status: channels.discord.token is unavailable"), + ), + ).toBe(true); + const joined = logs.join("\n"); + expect(joined).toContain("configured, secret unavailable in this command path"); + expect(joined).toContain("token:config (unavailable)"); + }); + + it("prefers resolved snapshots when command-local SecretRef resolution succeeds", async () => { + callGateway.mockRejectedValue(new Error("gateway closed")); + requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} }); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { secretResolved: true, channels: {} }, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + const { runtime, logs } = createRuntimeCapture(); + + await channelsStatusCommand({ probe: false }, runtime as never); + + const joined = logs.join("\n"); + expect(joined).toContain("configured"); + expect(joined).toContain("token:config"); + expect(joined).not.toContain("secret unavailable in this command path"); + expect(joined).not.toContain("token:config (unavailable)"); + }); +}); diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts index e9e0345871f..7a29b4993f5 100644 --- a/src/commands/channels/resolve.ts +++ b/src/commands/channels/resolve.ts @@ -75,7 +75,7 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti config: loadedRaw, commandName: "channels resolve", targetIds: getChannelsCommandSecretTargetIds(), - mode: "operational_readonly", + mode: "read_only_operational", }); for (const entry of diagnostics) { runtime.log(`[secrets] ${entry}`); diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index 3a56810e44c..2cbdaf17726 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -315,7 +315,7 @@ export async function channelsStatusCommand( config: cfg, commandName: "channels status", targetIds: getChannelsCommandSecretTargetIds(), - mode: "summary", + mode: "read_only_status", }); for (const entry of diagnostics) { runtime.log(`[secrets] ${entry}`); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index f616bfaba55..a06c090f9f4 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -330,7 +330,7 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi config: cfg, commandName: "doctor --fix", targetIds: getChannelsCommandSecretTargetIds(), - mode: "summary", + mode: "read_only_status", }); const hasConfiguredUnavailableToken = listTelegramAccountIds(cfg).some((accountId) => { const inspected = inspectTelegramAccount({ cfg, accountId }); diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts index c91ed2087a4..ca2bfb2989c 100644 --- a/src/commands/doctor-security.test.ts +++ b/src/commands/doctor-security.test.ts @@ -173,6 +173,32 @@ describe("noteSecurityWarnings gateway exposure", () => { expect(message).toContain("direct/DM targets by default"); }); + it("degrades safely when channel account resolution fails in read-only security checks", async () => { + pluginRegistry.list = [ + { + id: "whatsapp", + meta: { label: "WhatsApp" }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => { + throw new Error("missing secret"); + }, + isEnabled: () => true, + isConfigured: () => true, + }, + security: { + resolveDmPolicy: () => null, + }, + }, + ]; + + await noteSecurityWarnings({} as OpenClawConfig); + const message = lastMessage(); + expect(message).toContain("[secrets]"); + expect(message).toContain("failed to resolve account"); + expect(message).toContain("Run: openclaw security audit --deep"); + }); + it("skips heartbeat directPolicy warning when delivery is internal-only or explicit", async () => { const cfg = { agents: { diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index 5ba17c1c751..c489682f607 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -189,8 +189,14 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) { if (!plugin.security) { continue; } - const { defaultAccountId, account, enabled, configured } = - await resolveDefaultChannelAccountContext(plugin, cfg); + const { defaultAccountId, account, enabled, configured, diagnostics } = + await resolveDefaultChannelAccountContext(plugin, cfg, { + mode: "read_only", + commandName: "doctor", + }); + for (const diagnostic of diagnostics) { + warnings.push(`- [secrets] ${diagnostic}`); + } if (!enabled) { continue; } diff --git a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts index 68d865996d2..11a382db241 100644 --- a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts +++ b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts @@ -122,4 +122,52 @@ describe("doctor command", () => { "openclaw config set gateway.auth.mode password", ); }); + + it("keeps doctor read-only when gateway token is SecretRef-managed but unresolved", async () => { + mockDoctorConfigSnapshot({ + config: { + gateway: { + mode: "local", + auth: { + mode: "token", + token: { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN", + }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }, + }); + + const previousToken = process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_TOKEN; + note.mockClear(); + try { + await doctorCommand(createDoctorRuntime(), { + nonInteractive: true, + workspaceSuggestions: false, + }); + } finally { + if (previousToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = previousToken; + } + } + + const gatewayAuthNote = note.mock.calls.find((call) => call[1] === "Gateway auth"); + expect(gatewayAuthNote).toBeTruthy(); + expect(String(gatewayAuthNote?.[0])).toContain( + "Gateway token is managed via SecretRef and is currently unavailable.", + ); + expect(String(gatewayAuthNote?.[0])).toContain( + "Doctor will not overwrite gateway.auth.token with a plaintext value.", + ); + }); }); diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 452bcb3691b..46212816410 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -268,7 +268,7 @@ describe("gateway-status command", () => { expect(scopeLimitedWarning?.targetIds).toContain("localLoopback"); }); - it("surfaces unresolved SecretRef auth diagnostics in warnings", async () => { + it("suppresses unresolved SecretRef auth warnings when probe is reachable", async () => { const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => { mockLocalTokenEnvRefConfig(); @@ -276,6 +276,38 @@ describe("gateway-status command", () => { await runGatewayStatus(runtime, { timeout: "1000", json: true }); }); + expect(runtimeErrors).toHaveLength(0); + const parsed = JSON.parse(runtimeLogs.join("\n")) as { + warnings?: Array<{ code?: string; message?: string; targetIds?: string[] }>; + }; + const unresolvedWarning = parsed.warnings?.find( + (warning) => + warning.code === "auth_secretref_unresolved" && + warning.message?.includes("gateway.auth.token SecretRef is unresolved"), + ); + expect(unresolvedWarning).toBeUndefined(); + }); + + it("surfaces unresolved SecretRef auth diagnostics when probe fails", async () => { + const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); + await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => { + mockLocalTokenEnvRefConfig(); + probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "connection refused", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + await expect(runGatewayStatus(runtime, { timeout: "1000", json: true })).rejects.toThrow( + "__exit__:1", + ); + }); + expect(runtimeErrors).toHaveLength(0); const parsed = JSON.parse(runtimeLogs.join("\n")) as { warnings?: Array<{ code?: string; message?: string; targetIds?: string[] }>; diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index be0b9abf69a..ff2ba419cc8 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -229,7 +229,7 @@ export async function gatewayStatusCommand( }); } for (const result of probed) { - if (result.authDiagnostics.length === 0) { + if (result.authDiagnostics.length === 0 || isProbeReachable(result.probe)) { continue; } for (const diagnostic of result.authDiagnostics) { diff --git a/src/commands/health.ts b/src/commands/health.ts index 56705c96270..0e54eebadc7 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -1,7 +1,8 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; -import type { ChannelAccountSnapshot } from "../channels/plugins/types.js"; +import type { ChannelAccountSnapshot, ChannelPlugin } from "../channels/plugins/types.js"; +import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; import { withProgress } from "../cli/progress.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig, readBestEffortConfig } from "../config/config.js"; @@ -161,17 +162,91 @@ const buildSessionSummary = (storePath: string) => { } satisfies HealthSummary["sessions"]; }; -const isAccountEnabled = (account: unknown): boolean => { - if (!account || typeof account !== "object") { - return true; - } - const enabled = (account as { enabled?: boolean }).enabled; - return enabled !== false; -}; - const asRecord = (value: unknown): Record | null => value && typeof value === "object" ? (value as Record) : null; +function inspectHealthAccount( + plugin: ChannelPlugin, + cfg: OpenClawConfig, + accountId: string, +): unknown { + return ( + plugin.config.inspectAccount?.(cfg, accountId) ?? + inspectReadOnlyChannelAccount({ + channelId: plugin.id, + cfg, + accountId, + }) + ); +} + +function readBooleanField(value: unknown, key: string): boolean | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + return typeof record[key] === "boolean" ? record[key] : undefined; +} + +async function resolveHealthAccountContext(params: { + plugin: ChannelPlugin; + cfg: OpenClawConfig; + accountId: string; +}): Promise<{ + account: unknown; + enabled: boolean; + configured: boolean; + diagnostics: string[]; +}> { + const diagnostics: string[] = []; + let account: unknown; + try { + account = params.plugin.config.resolveAccount(params.cfg, params.accountId); + } catch (error) { + diagnostics.push( + `${params.plugin.id}:${params.accountId}: failed to resolve account (${formatErrorMessage(error)}).`, + ); + account = inspectHealthAccount(params.plugin, params.cfg, params.accountId); + } + + if (!account) { + return { + account: {}, + enabled: false, + configured: false, + diagnostics, + }; + } + + const enabledFallback = readBooleanField(account, "enabled") ?? true; + let enabled = enabledFallback; + if (params.plugin.config.isEnabled) { + try { + enabled = params.plugin.config.isEnabled(account, params.cfg); + } catch (error) { + enabled = enabledFallback; + diagnostics.push( + `${params.plugin.id}:${params.accountId}: failed to evaluate enabled state (${formatErrorMessage(error)}).`, + ); + } + } + + const configuredFallback = readBooleanField(account, "configured") ?? true; + let configured = configuredFallback; + if (params.plugin.config.isConfigured) { + try { + configured = await params.plugin.config.isConfigured(account, params.cfg); + } catch (error) { + configured = configuredFallback; + diagnostics.push( + `${params.plugin.id}:${params.accountId}: failed to evaluate configured state (${formatErrorMessage(error)}).`, + ); + } + } + + return { account, enabled, configured, diagnostics }; +} + const formatProbeLine = (probe: unknown, opts: { botUsernames?: string[] } = {}): string | null => { const record = asRecord(probe); if (!record) { @@ -416,13 +491,14 @@ export async function getHealthSnapshot(params?: { const accountSummaries: Record = {}; for (const accountId of accountIdsToProbe) { - const account = plugin.config.resolveAccount(cfg, accountId); - const enabled = plugin.config.isEnabled - ? plugin.config.isEnabled(account, cfg) - : isAccountEnabled(account); - const configured = plugin.config.isConfigured - ? await plugin.config.isConfigured(account, cfg) - : true; + const { account, enabled, configured, diagnostics } = await resolveHealthAccountContext({ + plugin, + cfg, + accountId, + }); + if (diagnostics.length > 0) { + debugHealth("account.diagnostics", { channel: plugin.id, accountId, diagnostics }); + } let probe: unknown; let lastProbeAt: number | null = null; @@ -588,16 +664,20 @@ export async function healthCommand( ` ${plugin.id}: accounts=${accountIds.join(", ") || "(none)"} default=${defaultAccountId}`, ); for (const accountId of accountIds) { - const account = plugin.config.resolveAccount(cfg, accountId); + const { account, configured, diagnostics } = await resolveHealthAccountContext({ + plugin, + cfg, + accountId, + }); const record = asRecord(account); const tokenSource = record && typeof record.tokenSource === "string" ? record.tokenSource : undefined; - const configured = plugin.config.isConfigured - ? await plugin.config.isConfigured(account, cfg) - : true; runtime.log( ` - ${accountId}: configured=${configured}${tokenSource ? ` tokenSource=${tokenSource}` : ""}`, ); + for (const diagnostic of diagnostics) { + runtime.log(` ! ${diagnostic}`); + } } } runtime.log(info("[debug] bindings map")); @@ -691,13 +771,31 @@ export async function healthCommand( defaultAccountId, boundAccounts, }); - const account = plugin.config.resolveAccount(cfg, accountId); - plugin.status.logSelfId({ - account, + const accountContext = await resolveHealthAccountContext({ + plugin, cfg, - runtime, - includeChannelPrefix: true, + accountId, }); + if (!accountContext.enabled || !accountContext.configured) { + continue; + } + if (accountContext.diagnostics.length > 0) { + continue; + } + try { + plugin.status.logSelfId({ + account: accountContext.account, + cfg, + runtime, + includeChannelPrefix: true, + }); + } catch (error) { + debugHealth("logSelfId.failed", { + channel: plugin.id, + accountId, + error: formatErrorMessage(error), + }); + } } if (resolvedAgents.length > 0) { diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index fa4e3dcb435..b643c30ff33 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -48,7 +48,7 @@ export async function statusAllCommand( config: loadedRaw, commandName: "status --all", targetIds: getStatusCommandSecretTargetIds(), - mode: "summary", + mode: "read_only_status", }); const osSummary = resolveOsSummary(); const snap = await readConfigFileSnapshot().catch(() => null); diff --git a/src/commands/status.link-channel.test.ts b/src/commands/status.link-channel.test.ts new file mode 100644 index 00000000000..14315ef1a35 --- /dev/null +++ b/src/commands/status.link-channel.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; + +const pluginRegistry = vi.hoisted(() => ({ list: [] as unknown[] })); + +vi.mock("../channels/plugins/index.js", () => ({ + listChannelPlugins: () => pluginRegistry.list, +})); + +import { resolveLinkChannelContext } from "./status.link-channel.js"; + +describe("resolveLinkChannelContext", () => { + it("returns linked context from read-only inspected account state", async () => { + const account = { configured: true, enabled: true }; + pluginRegistry.list = [ + { + id: "discord", + meta: { label: "Discord" }, + config: { + listAccountIds: () => ["default"], + inspectAccount: () => account, + resolveAccount: () => { + throw new Error("should not be called in read-only mode"); + }, + }, + status: { + buildChannelSummary: () => ({ linked: true, authAgeMs: 1234 }), + }, + }, + ]; + + const result = await resolveLinkChannelContext({} as OpenClawConfig); + expect(result?.linked).toBe(true); + expect(result?.authAgeMs).toBe(1234); + expect(result?.account).toBe(account); + }); + + it("degrades safely when account resolution throws", async () => { + pluginRegistry.list = [ + { + id: "discord", + meta: { label: "Discord" }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => { + throw new Error("missing secret"); + }, + }, + }, + ]; + + const result = await resolveLinkChannelContext({} as OpenClawConfig); + expect(result).toBeNull(); + }); +}); diff --git a/src/commands/status.link-channel.ts b/src/commands/status.link-channel.ts index 2ee0eee4f2e..4f192f31623 100644 --- a/src/commands/status.link-channel.ts +++ b/src/commands/status.link-channel.ts @@ -16,7 +16,10 @@ export async function resolveLinkChannelContext( ): Promise { for (const plugin of listChannelPlugins()) { const { defaultAccountId, account, enabled, configured } = - await resolveDefaultChannelAccountContext(plugin, cfg); + await resolveDefaultChannelAccountContext(plugin, cfg, { + mode: "read_only", + commandName: "status", + }); const snapshot = plugin.config.describeAccount ? plugin.config.describeAccount(account, cfg) : ({ diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 8de4aae7745..f7661573578 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -197,7 +197,7 @@ async function scanStatusJsonFast(opts: { config: loadedRaw, commandName: "status --json", targetIds: getStatusCommandSecretTargetIds(), - mode: "summary", + mode: "read_only_status", }); if (hasPotentialConfiguredChannels(cfg)) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); @@ -302,7 +302,7 @@ export async function scanStatus( config: loadedRaw, commandName: "status", targetIds: getStatusCommandSecretTargetIds(), - mode: "summary", + mode: "read_only_status", }); const osSummary = resolveOsSummary(); const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 5cc71b6e950..f3dfd37064a 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -512,6 +512,11 @@ describe("statusCommand", () => { await statusCommand({ json: true }, runtime as never); const payload = JSON.parse(String(runtimeLogMock.mock.calls.at(-1)?.[0])); expect(payload.gateway.error ?? payload.gateway.authWarning ?? null).not.toBeNull(); + if (Array.isArray(payload.secretDiagnostics) && payload.secretDiagnostics.length > 0) { + expect( + payload.secretDiagnostics.some((entry: string) => entry.includes("gateway.auth.token")), + ).toBe(true); + } expect(runtime.error).not.toHaveBeenCalled(); }); diff --git a/src/gateway/call.ts b/src/gateway/call.ts index f163a45ef06..300391b6047 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -330,11 +330,8 @@ async function resolveGatewaySecretInputString(params: { value: params.value, env: params.env, normalize: trimToUndefined, - onResolveRefError: (error) => { - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`${params.path} secret reference could not be resolved: ${detail}`, { - cause: error, - }); + onResolveRefError: () => { + throw new GatewaySecretRefUnavailableError(params.path); }, }); if (!value) { diff --git a/src/gateway/probe-auth.ts b/src/gateway/probe-auth.ts index 64980be601e..2c624acaa00 100644 --- a/src/gateway/probe-auth.ts +++ b/src/gateway/probe-auth.ts @@ -54,10 +54,22 @@ export function resolveGatewayProbeAuthSafe(params: { cfg: OpenClawConfig; mode: "local" | "remote"; env?: NodeJS.ProcessEnv; + explicitAuth?: ExplicitGatewayAuth; }): { auth: { token?: string; password?: string }; warning?: string; } { + const explicitToken = params.explicitAuth?.token?.trim(); + const explicitPassword = params.explicitAuth?.password?.trim(); + if (explicitToken || explicitPassword) { + return { + auth: { + ...(explicitToken ? { token: explicitToken } : {}), + ...(explicitPassword ? { password: explicitPassword } : {}), + }, + }; + } + try { return { auth: resolveGatewayProbeAuth(params) }; } catch (error) { diff --git a/src/security/audit-channel.ts b/src/security/audit-channel.ts index ca0e69722e3..bf501cf659b 100644 --- a/src/security/audit-channel.ts +++ b/src/security/audit-channel.ts @@ -14,6 +14,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js"; import type { OpenClawConfig } from "../config/config.js"; import { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; import type { SecurityAuditFinding, SecurityAuditSeverity } from "./audit.js"; @@ -164,6 +165,7 @@ export async function collectChannelSecurityFindings(params: { plugin: (typeof params.plugins)[number], accountId: string, ) => { + const diagnostics: string[] = []; const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId); const resolvedInspectedAccount = inspectChannelAccount(plugin, params.cfg, accountId); const sourceInspection = sourceInspectedAccount as { @@ -174,8 +176,27 @@ export async function collectChannelSecurityFindings(params: { enabled?: boolean; configured?: boolean; } | null; - const resolvedAccount = - resolvedInspectedAccount ?? plugin.config.resolveAccount(params.cfg, accountId); + let resolvedAccount = resolvedInspectedAccount; + if (!resolvedAccount) { + try { + resolvedAccount = plugin.config.resolveAccount(params.cfg, accountId); + } catch (error) { + diagnostics.push( + `${plugin.id}:${accountId}: failed to resolve account (${formatErrorMessage(error)}).`, + ); + } + } + if (!resolvedAccount && sourceInspectedAccount) { + resolvedAccount = sourceInspectedAccount; + } + if (!resolvedAccount) { + return { + account: {}, + enabled: false, + configured: false, + diagnostics, + }; + } const useSourceUnavailableAccount = Boolean( sourceInspectedAccount && hasConfiguredUnavailableCredentialStatus(sourceInspectedAccount) && @@ -185,23 +206,49 @@ export async function collectChannelSecurityFindings(params: { const account = useSourceUnavailableAccount ? sourceInspectedAccount : resolvedAccount; const selectedInspection = useSourceUnavailableAccount ? sourceInspection : resolvedInspection; const accountRecord = asAccountRecord(account); - const enabled = + let enabled = typeof selectedInspection?.enabled === "boolean" ? selectedInspection.enabled : typeof accountRecord?.enabled === "boolean" ? accountRecord.enabled - : plugin.config.isEnabled - ? plugin.config.isEnabled(account, params.cfg) - : true; - const configured = + : true; + if ( + typeof selectedInspection?.enabled !== "boolean" && + typeof accountRecord?.enabled !== "boolean" && + plugin.config.isEnabled + ) { + try { + enabled = plugin.config.isEnabled(account, params.cfg); + } catch (error) { + enabled = false; + diagnostics.push( + `${plugin.id}:${accountId}: failed to evaluate enabled state (${formatErrorMessage(error)}).`, + ); + } + } + + let configured = typeof selectedInspection?.configured === "boolean" ? selectedInspection.configured : typeof accountRecord?.configured === "boolean" ? accountRecord.configured - : plugin.config.isConfigured - ? await plugin.config.isConfigured(account, params.cfg) - : true; - return { account, enabled, configured }; + : true; + if ( + typeof selectedInspection?.configured !== "boolean" && + typeof accountRecord?.configured !== "boolean" && + plugin.config.isConfigured + ) { + try { + configured = await plugin.config.isConfigured(account, params.cfg); + } catch (error) { + configured = false; + diagnostics.push( + `${plugin.id}:${accountId}: failed to evaluate configured state (${formatErrorMessage(error)}).`, + ); + } + } + + return { account, enabled, configured, diagnostics }; }; const coerceNativeSetting = (value: unknown): boolean | "auto" | undefined => { @@ -298,7 +345,20 @@ export async function collectChannelSecurityFindings(params: { plugin.id, accountId, ); - const { account, enabled, configured } = await resolveChannelAuditAccount(plugin, accountId); + const { account, enabled, configured, diagnostics } = await resolveChannelAuditAccount( + plugin, + accountId, + ); + for (const diagnostic of diagnostics) { + findings.push({ + checkId: `channels.${plugin.id}.account.read_only_resolution`, + severity: "warn", + title: `${plugin.meta.label ?? plugin.id} account could not be fully resolved`, + detail: diagnostic, + remediation: + "Ensure referenced secrets are available in this shell or run with a running gateway snapshot so security audit can inspect the full channel configuration.", + }); + } if (!enabled) { continue; } diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index dd1040e1263..dedc789773c 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -346,6 +346,43 @@ description: test skill expectNoFinding(res, "gateway.bind_no_auth"); }); + it("does not flag missing gateway auth when read-only scrubbed config omits unavailable auth SecretRefs", async () => { + const sourceConfig: OpenClawConfig = { + gateway: { + bind: "lan", + auth: { + token: { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN", + }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + const resolvedConfig: OpenClawConfig = { + gateway: { + bind: "lan", + auth: {}, + }, + secrets: sourceConfig.secrets, + }; + + const res = await runSecurityAudit({ + config: resolvedConfig, + sourceConfig, + env: {}, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expectNoFinding(res, "gateway.bind_no_auth"); + }); + it("evaluates gateway auth rate-limit warning based on configuration", async () => { const cases: Array<{ name: string; @@ -1805,11 +1842,7 @@ description: test skill it("warns when multiple DM senders share the main session", async () => { const cfg: OpenClawConfig = { session: { dmScope: "main" }, - channels: { - whatsapp: { - enabled: true, - }, - }, + channels: { whatsapp: { enabled: true } }, }; const plugins: ChannelPlugin[] = [ { @@ -1984,6 +2017,40 @@ description: test skill }); }); + it("adds a read-only resolution warning when channel account resolveAccount throws", async () => { + const plugin = stubChannelPlugin({ + id: "zalouser", + label: "Zalo Personal", + listAccountIds: () => ["default"], + resolveAccount: () => { + throw new Error("missing SecretRef"); + }, + }); + + const cfg: OpenClawConfig = { + channels: { + zalouser: { + enabled: true, + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: true, + plugins: [plugin], + }); + + const finding = res.findings.find( + (entry) => entry.checkId === "channels.zalouser.account.read_only_resolution", + ); + expect(finding?.severity).toBe("warn"); + expect(finding?.title).toContain("could not be fully resolved"); + expect(finding?.detail).toContain("zalouser:default: failed to resolve account"); + expect(finding?.detail).toContain("missing SecretRef"); + }); + it("keeps Slack HTTP slash-command findings when resolved inspection only exposes signingSecret status", async () => { await withChannelSecurityStateDir(async () => { const sourceConfig: OpenClawConfig = { diff --git a/src/security/audit.ts b/src/security/audit.ts index dbbfb9651be..d3c1337e042 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -113,6 +113,8 @@ export type SecurityAuditOptions = { configSnapshot?: ConfigFileSnapshot | null; /** Optional cache for code-safety summaries across repeated deep audits. */ codeSafetySummaryCache?: Map>; + /** Optional explicit auth for deep gateway probe. */ + deepProbeAuth?: { token?: string; password?: string }; }; type AuditExecutionContext = { @@ -132,6 +134,7 @@ type AuditExecutionContext = { plugins?: ReturnType; configSnapshot: ConfigFileSnapshot | null; codeSafetySummaryCache: Map>; + deepProbeAuth?: { token?: string; password?: string }; }; function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary { @@ -341,6 +344,7 @@ async function collectFilesystemFindings(params: { function collectGatewayConfigFindings( cfg: OpenClawConfig, + sourceConfig: OpenClawConfig, env: NodeJS.ProcessEnv, ): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; @@ -365,18 +369,18 @@ function collectGatewayConfigFindings( hasNonEmptyString(env.OPENCLAW_GATEWAY_PASSWORD) || hasNonEmptyString(env.CLAWDBOT_GATEWAY_PASSWORD); const tokenConfiguredFromConfig = hasConfiguredSecretInput( - cfg.gateway?.auth?.token, - cfg.secrets?.defaults, + sourceConfig.gateway?.auth?.token, + sourceConfig.secrets?.defaults, ); const passwordConfiguredFromConfig = hasConfiguredSecretInput( - cfg.gateway?.auth?.password, - cfg.secrets?.defaults, + sourceConfig.gateway?.auth?.password, + sourceConfig.secrets?.defaults, ); const remoteTokenConfigured = hasConfiguredSecretInput( - cfg.gateway?.remote?.token, - cfg.secrets?.defaults, + sourceConfig.gateway?.remote?.token, + sourceConfig.secrets?.defaults, ); - const explicitAuthMode = cfg.gateway?.auth?.mode; + const explicitAuthMode = sourceConfig.gateway?.auth?.mode; const tokenCanWin = hasToken || envTokenConfigured || tokenConfiguredFromConfig || remoteTokenConfigured; const passwordCanWin = @@ -1062,6 +1066,7 @@ async function maybeProbeGateway(params: { env: NodeJS.ProcessEnv; timeoutMs: number; probe: typeof probeGateway; + explicitAuth?: { token?: string; password?: string }; }): Promise<{ deep: SecurityAuditReport["deep"]; authWarning?: string; @@ -1075,8 +1080,18 @@ async function maybeProbeGateway(params: { const authResolution = !isRemoteMode || remoteUrlMissing - ? resolveGatewayProbeAuthSafe({ cfg: params.cfg, env: params.env, mode: "local" }) - : resolveGatewayProbeAuthSafe({ cfg: params.cfg, env: params.env, mode: "remote" }); + ? resolveGatewayProbeAuthSafe({ + cfg: params.cfg, + env: params.env, + mode: "local", + explicitAuth: params.explicitAuth, + }) + : resolveGatewayProbeAuthSafe({ + cfg: params.cfg, + env: params.env, + mode: "remote", + explicitAuth: params.explicitAuth, + }); const res = await params .probe({ url, auth: authResolution.auth, timeoutMs: params.timeoutMs }) .catch((err) => ({ @@ -1144,6 +1159,7 @@ async function createAuditExecutionContext( plugins: opts.plugins, configSnapshot, codeSafetySummaryCache: opts.codeSafetySummaryCache ?? new Map>(), + deepProbeAuth: opts.deepProbeAuth, }; } @@ -1155,7 +1171,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise Date: Sun, 15 Mar 2026 19:55:08 -0700 Subject: [PATCH 148/943] Gateway: add presence-only probe mode for status --- src/commands/status.scan.test.ts | 3 +++ src/commands/status.scan.ts | 1 + src/gateway/probe.test.ts | 14 ++++++++++++++ src/gateway/probe.ts | 19 ++++++++++++++++++- 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index b94f1f0ece0..55f323f0b4a 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -246,6 +246,9 @@ describe("scanStatus", () => { await scanStatus({ json: true }, {} as never); expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "channels" }); + expect(mocks.probeGateway).toHaveBeenCalledWith( + expect.objectContaining({ detailLevel: "presence" }), + ); expect(mocks.callGateway).not.toHaveBeenCalledWith( expect.objectContaining({ method: "channels.status" }), ); diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index f7661573578..88dd21e7177 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -98,6 +98,7 @@ async function resolveGatewayProbeSnapshot(params: { url: gatewayConnection.url, auth: gatewayProbeAuthResolution.auth, timeoutMs: Math.min(params.opts.all ? 5000 : 2500, params.opts.timeoutMs ?? 10_000), + detailLevel: "presence", }).catch(() => null); if (gatewayProbeAuthWarning && gatewayProbe?.ok === false) { gatewayProbe.error = gatewayProbe.error diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index 6cd7d64fc51..f91dc5148d5 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -81,4 +81,18 @@ describe("probeGateway", () => { expect(result.ok).toBe(true); expect(gatewayClientState.requests).toEqual([]); }); + + it("fetches only presence for presence-only probes", async () => { + const result = await probeGateway({ + url: "ws://127.0.0.1:18789", + timeoutMs: 1_000, + detailLevel: "presence", + }); + + expect(result.ok).toBe(true); + expect(gatewayClientState.requests).toEqual(["system-presence"]); + expect(result.health).toBeNull(); + expect(result.status).toBeNull(); + expect(result.configSnapshot).toBeNull(); + }); }); diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index 40740987fb0..87a77b8bfef 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -34,6 +34,7 @@ export async function probeGateway(opts: { auth?: GatewayProbeAuth; timeoutMs: number; includeDetails?: boolean; + detailLevel?: "none" | "presence" | "full"; }): Promise { const startedAt = Date.now(); const instanceId = randomUUID(); @@ -49,6 +50,8 @@ export async function probeGateway(opts: { } })(); + const detailLevel = opts.includeDetails === false ? "none" : (opts.detailLevel ?? "full"); + return await new Promise((resolve) => { let settled = false; const settle = (result: Omit) => { @@ -79,7 +82,7 @@ export async function probeGateway(opts: { }, onHelloOk: async () => { connectLatencyMs = Date.now() - startedAt; - if (opts.includeDetails === false) { + if (detailLevel === "none") { settle({ ok: true, connectLatencyMs, @@ -93,6 +96,20 @@ export async function probeGateway(opts: { return; } try { + if (detailLevel === "presence") { + const presence = await client.request("system-presence"); + settle({ + ok: true, + connectLatencyMs, + error: null, + close, + health: null, + status: null, + presence: Array.isArray(presence) ? (presence as SystemPresence[]) : null, + configSnapshot: null, + }); + return; + } const [health, status, presence, configSnapshot] = await Promise.all([ client.request("health"), client.request("status"), From 84c0326f4de9970d0aac8c6187077d3e2cd24561 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 19:52:17 -0700 Subject: [PATCH 149/943] refactor: move group access into setup wizard --- extensions/discord/src/setup-core.ts | 2 +- extensions/matrix/src/setup-surface.ts | 137 +++++++------- extensions/msteams/src/setup-surface.ts | 179 +++++++++--------- extensions/slack/src/setup-core.ts | 2 +- extensions/twitch/src/setup-surface.ts | 68 ++++--- ...s => setup-group-access-configure.test.ts} | 41 +++- ...ure.ts => setup-group-access-configure.ts} | 15 +- ...ess.test.ts => setup-group-access.test.ts} | 23 ++- ...hannel-access.ts => setup-group-access.ts} | 8 +- src/channels/plugins/setup-wizard.ts | 42 ++-- src/plugin-sdk/googlechat.ts | 1 - src/plugin-sdk/irc.ts | 1 - src/plugin-sdk/tlon.ts | 1 - src/plugin-sdk/zalo.ts | 1 - src/plugin-sdk/zalouser.ts | 1 - 15 files changed, 305 insertions(+), 217 deletions(-) rename src/channels/plugins/{onboarding/channel-access-configure.test.ts => setup-group-access-configure.test.ts} (77%) rename src/channels/plugins/{onboarding/channel-access-configure.ts => setup-group-access-configure.ts} (65%) rename src/channels/plugins/{onboarding/channel-access.test.ts => setup-group-access.test.ts} (84%) rename src/channels/plugins/{onboarding/channel-access.ts => setup-group-access.ts} (92%) diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index cec63dd01ec..f75a0312416 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -251,7 +251,7 @@ export function createDiscordSetupWizardProxy( prompter: { note: (message: string, title?: string) => Promise }; }) => { const wizard = (await loadWizard()).discordSetupWizard; - if (!wizard.groupAccess) { + if (!wizard.groupAccess?.resolveAllowlist) { return entries.map((input) => ({ input, resolved: false })); } try { diff --git a/extensions/matrix/src/setup-surface.ts b/extensions/matrix/src/setup-surface.ts index e01e0d57750..b475b6bf742 100644 --- a/extensions/matrix/src/setup-surface.ts +++ b/extensions/matrix/src/setup-surface.ts @@ -1,5 +1,4 @@ import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; -import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js"; import { addWildcardAllowFrom, buildSingleChannelSecretPromptState, @@ -171,6 +170,78 @@ function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) { }; } +async function resolveMatrixGroupRooms(params: { + cfg: CoreConfig; + entries: string[]; + prompter: Pick; +}): Promise { + if (params.entries.length === 0) { + return []; + } + try { + const resolvedIds: string[] = []; + const unresolved: string[] = []; + for (const entry of params.entries) { + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + const cleaned = trimmed.replace(/^(room|channel):/i, "").trim(); + if (cleaned.startsWith("!") && cleaned.includes(":")) { + resolvedIds.push(cleaned); + continue; + } + const matches = await listMatrixDirectoryGroupsLive({ + cfg: params.cfg, + query: trimmed, + limit: 10, + }); + const exact = matches.find( + (match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(), + ); + const best = exact ?? matches[0]; + if (best?.id) { + resolvedIds.push(best.id); + } else { + unresolved.push(entry); + } + } + const roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; + const resolution = formatResolvedUnresolvedNote({ + resolved: resolvedIds, + unresolved, + }); + if (resolution) { + await params.prompter.note(resolution, "Matrix rooms"); + } + return roomKeys; + } catch (err) { + await params.prompter.note( + `Room lookup failed; keeping entries as typed. ${String(err)}`, + "Matrix rooms", + ); + return params.entries.map((entry) => entry.trim()).filter(Boolean); + } +} + +const matrixGroupAccess: NonNullable = { + label: "Matrix rooms", + placeholder: "!roomId:server, #alias:server, Project Room", + currentPolicy: ({ cfg }) => cfg.channels?.matrix?.groupPolicy ?? "allowlist", + currentEntries: ({ cfg }) => + Object.keys(cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms ?? {}), + updatePrompt: ({ cfg }) => Boolean(cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms), + setPolicy: ({ cfg, policy }) => setMatrixGroupPolicy(cfg as CoreConfig, policy), + resolveAllowlist: async ({ cfg, entries, prompter }) => + await resolveMatrixGroupRooms({ + cfg: cfg as CoreConfig, + entries, + prompter, + }), + applyAllowlist: ({ cfg, resolved }) => + setMatrixGroupRooms(cfg as CoreConfig, resolved as string[]), +}; + const matrixDmPolicy: ChannelOnboardingDmPolicy = { label: "Matrix", channel, @@ -386,72 +457,10 @@ export const matrixSetupWizard: ChannelSetupWizard = { next = await promptMatrixAllowFrom({ cfg: next, prompter }); } - const existingGroups = next.channels?.matrix?.groups ?? next.channels?.matrix?.rooms; - const accessConfig = await promptChannelAccessConfig({ - prompter, - label: "Matrix rooms", - currentPolicy: next.channels?.matrix?.groupPolicy ?? "allowlist", - currentEntries: Object.keys(existingGroups ?? {}), - placeholder: "!roomId:server, #alias:server, Project Room", - updatePrompt: Boolean(existingGroups), - }); - if (accessConfig) { - if (accessConfig.policy !== "allowlist") { - next = setMatrixGroupPolicy(next, accessConfig.policy); - } else { - let roomKeys = accessConfig.entries; - if (accessConfig.entries.length > 0) { - try { - const resolvedIds: string[] = []; - const unresolved: string[] = []; - for (const entry of accessConfig.entries) { - const trimmed = entry.trim(); - if (!trimmed) { - continue; - } - const cleaned = trimmed.replace(/^(room|channel):/i, "").trim(); - if (cleaned.startsWith("!") && cleaned.includes(":")) { - resolvedIds.push(cleaned); - continue; - } - const matches = await listMatrixDirectoryGroupsLive({ - cfg: next, - query: trimmed, - limit: 10, - }); - const exact = matches.find( - (match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(), - ); - const best = exact ?? matches[0]; - if (best?.id) { - resolvedIds.push(best.id); - } else { - unresolved.push(entry); - } - } - roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; - const resolution = formatResolvedUnresolvedNote({ - resolved: resolvedIds, - unresolved, - }); - if (resolution) { - await prompter.note(resolution, "Matrix rooms"); - } - } catch (err) { - await prompter.note( - `Room lookup failed; keeping entries as typed. ${String(err)}`, - "Matrix rooms", - ); - } - } - next = setMatrixGroupPolicy(next, "allowlist"); - next = setMatrixGroupRooms(next, roomKeys); - } - } - return { cfg: next }; }, dmPolicy: matrixDmPolicy, + groupAccess: matrixGroupAccess, disable: (cfg) => ({ ...(cfg as CoreConfig), channels: { diff --git a/extensions/msteams/src/setup-surface.ts b/extensions/msteams/src/setup-surface.ts index f8db90e5079..9e39a24563e 100644 --- a/extensions/msteams/src/setup-surface.ts +++ b/extensions/msteams/src/setup-surface.ts @@ -1,5 +1,4 @@ import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; -import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js"; import { mergeAllowFromEntries, setTopLevelChannelAllowFrom, @@ -191,6 +190,96 @@ function setMSTeamsTeamsAllowlist( }; } +function listMSTeamsGroupEntries(cfg: OpenClawConfig): string[] { + return Object.entries(cfg.channels?.msteams?.teams ?? {}).flatMap(([teamKey, value]) => { + const channels = value?.channels ?? {}; + const channelKeys = Object.keys(channels); + if (channelKeys.length === 0) { + return [teamKey]; + } + return channelKeys.map((channelKey) => `${teamKey}/${channelKey}`); + }); +} + +async function resolveMSTeamsGroupAllowlist(params: { + cfg: OpenClawConfig; + entries: string[]; + prompter: Pick; +}): Promise> { + let resolvedEntries = params.entries + .map((entry) => parseMSTeamsTeamEntry(entry)) + .filter(Boolean) as Array<{ teamKey: string; channelKey?: string }>; + if (params.entries.length === 0 || !resolveMSTeamsCredentials(params.cfg.channels?.msteams)) { + return resolvedEntries; + } + try { + const lookups = await resolveMSTeamsChannelAllowlist({ + cfg: params.cfg, + entries: params.entries, + }); + const resolvedChannels = lookups.filter( + (entry) => entry.resolved && entry.teamId && entry.channelId, + ); + const resolvedTeams = lookups.filter( + (entry) => entry.resolved && entry.teamId && !entry.channelId, + ); + const unresolved = lookups.filter((entry) => !entry.resolved).map((entry) => entry.input); + resolvedEntries = [ + ...resolvedChannels.map((entry) => ({ + teamKey: entry.teamId as string, + channelKey: entry.channelId as string, + })), + ...resolvedTeams.map((entry) => ({ + teamKey: entry.teamId as string, + })), + ...unresolved.map((entry) => parseMSTeamsTeamEntry(entry)).filter(Boolean), + ] as Array<{ teamKey: string; channelKey?: string }>; + const summary: string[] = []; + if (resolvedChannels.length > 0) { + summary.push( + `Resolved channels: ${resolvedChannels + .map((entry) => entry.channelId) + .filter(Boolean) + .join(", ")}`, + ); + } + if (resolvedTeams.length > 0) { + summary.push( + `Resolved teams: ${resolvedTeams + .map((entry) => entry.teamId) + .filter(Boolean) + .join(", ")}`, + ); + } + if (unresolved.length > 0) { + summary.push(`Unresolved (kept as typed): ${unresolved.join(", ")}`); + } + if (summary.length > 0) { + await params.prompter.note(summary.join("\n"), "MS Teams channels"); + } + return resolvedEntries; + } catch (err) { + await params.prompter.note( + `Channel lookup failed; keeping entries as typed. ${String(err)}`, + "MS Teams channels", + ); + return resolvedEntries; + } +} + +const msteamsGroupAccess: NonNullable = { + label: "MS Teams channels", + placeholder: "Team Name/Channel Name, teamId/conversationId", + currentPolicy: ({ cfg }) => cfg.channels?.msteams?.groupPolicy ?? "allowlist", + currentEntries: ({ cfg }) => listMSTeamsGroupEntries(cfg), + updatePrompt: ({ cfg }) => Boolean(cfg.channels?.msteams?.teams), + setPolicy: ({ cfg, policy }) => setMSTeamsGroupPolicy(cfg, policy), + resolveAllowlist: async ({ cfg, entries, prompter }) => + await resolveMSTeamsGroupAllowlist({ cfg, entries, prompter }), + applyAllowlist: ({ cfg, resolved }) => + setMSTeamsTeamsAllowlist(cfg, resolved as Array<{ teamKey: string; channelKey?: string }>), +}; + const msteamsDmPolicy: ChannelOnboardingDmPolicy = { label: "MS Teams", channel, @@ -290,96 +379,10 @@ export const msteamsSetupWizard: ChannelSetupWizard = { }; } - const currentEntries = Object.entries(next.channels?.msteams?.teams ?? {}).flatMap( - ([teamKey, value]) => { - const channels = value?.channels ?? {}; - const channelKeys = Object.keys(channels); - if (channelKeys.length === 0) { - return [teamKey]; - } - return channelKeys.map((channelKey) => `${teamKey}/${channelKey}`); - }, - ); - const accessConfig = await promptChannelAccessConfig({ - prompter, - label: "MS Teams channels", - currentPolicy: next.channels?.msteams?.groupPolicy ?? "allowlist", - currentEntries, - placeholder: "Team Name/Channel Name, teamId/conversationId", - updatePrompt: Boolean(next.channels?.msteams?.teams), - }); - if (accessConfig) { - if (accessConfig.policy !== "allowlist") { - next = setMSTeamsGroupPolicy(next, accessConfig.policy); - } else { - let entries = accessConfig.entries - .map((entry) => parseMSTeamsTeamEntry(entry)) - .filter(Boolean) as Array<{ teamKey: string; channelKey?: string }>; - if (accessConfig.entries.length > 0 && resolveMSTeamsCredentials(next.channels?.msteams)) { - try { - const resolvedEntries = await resolveMSTeamsChannelAllowlist({ - cfg: next, - entries: accessConfig.entries, - }); - const resolvedChannels = resolvedEntries.filter( - (entry) => entry.resolved && entry.teamId && entry.channelId, - ); - const resolvedTeams = resolvedEntries.filter( - (entry) => entry.resolved && entry.teamId && !entry.channelId, - ); - const unresolved = resolvedEntries - .filter((entry) => !entry.resolved) - .map((entry) => entry.input); - - entries = [ - ...resolvedChannels.map((entry) => ({ - teamKey: entry.teamId as string, - channelKey: entry.channelId as string, - })), - ...resolvedTeams.map((entry) => ({ - teamKey: entry.teamId as string, - })), - ...unresolved.map((entry) => parseMSTeamsTeamEntry(entry)).filter(Boolean), - ] as Array<{ teamKey: string; channelKey?: string }>; - - if (resolvedChannels.length > 0 || resolvedTeams.length > 0 || unresolved.length > 0) { - const summary: string[] = []; - if (resolvedChannels.length > 0) { - summary.push( - `Resolved channels: ${resolvedChannels - .map((entry) => entry.channelId) - .filter(Boolean) - .join(", ")}`, - ); - } - if (resolvedTeams.length > 0) { - summary.push( - `Resolved teams: ${resolvedTeams - .map((entry) => entry.teamId) - .filter(Boolean) - .join(", ")}`, - ); - } - if (unresolved.length > 0) { - summary.push(`Unresolved (kept as typed): ${unresolved.join(", ")}`); - } - await prompter.note(summary.join("\n"), "MS Teams channels"); - } - } catch (err) { - await prompter.note( - `Channel lookup failed; keeping entries as typed. ${String(err)}`, - "MS Teams channels", - ); - } - } - next = setMSTeamsGroupPolicy(next, "allowlist"); - next = setMSTeamsTeamsAllowlist(next, entries); - } - } - return { cfg: next, accountId: DEFAULT_ACCOUNT_ID }; }, dmPolicy: msteamsDmPolicy, + groupAccess: msteamsGroupAccess, disable: (cfg) => ({ ...cfg, channels: { diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index 0cf7903e6d4..c30f0134009 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -455,7 +455,7 @@ export function createSlackSetupWizardProxy( }) => { try { const wizard = (await loadWizard()).slackSetupWizard; - if (!wizard.groupAccess) { + if (!wizard.groupAccess?.resolveAllowlist) { return entries; } return await wizard.groupAccess.resolveAllowlist({ diff --git a/extensions/twitch/src/setup-surface.ts b/extensions/twitch/src/setup-surface.ts index 776644a2d23..bff81f47fff 100644 --- a/extensions/twitch/src/setup-surface.ts +++ b/extensions/twitch/src/setup-surface.ts @@ -3,7 +3,6 @@ */ import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; -import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; @@ -228,6 +227,26 @@ function setTwitchAccessControl( }); } +function resolveTwitchGroupPolicy(cfg: OpenClawConfig): "open" | "allowlist" | "disabled" { + const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID); + if (account?.allowedRoles?.includes("all")) { + return "open"; + } + if (account?.allowedRoles?.includes("moderator")) { + return "allowlist"; + } + return "disabled"; +} + +function setTwitchGroupPolicy( + cfg: OpenClawConfig, + policy: "open" | "allowlist" | "disabled", +): OpenClawConfig { + const allowedRoles: TwitchRole[] = + policy === "open" ? ["all"] : policy === "allowlist" ? ["moderator", "vip"] : []; + return setTwitchAccessControl(cfg, allowedRoles, true); +} + const twitchDmPolicy: ChannelOnboardingDmPolicy = { label: "Twitch", channel, @@ -270,6 +289,24 @@ const twitchDmPolicy: ChannelOnboardingDmPolicy = { }, }; +const twitchGroupAccess: NonNullable = { + label: "Twitch chat", + placeholder: "", + skipAllowlistEntries: true, + currentPolicy: ({ cfg }) => resolveTwitchGroupPolicy(cfg as OpenClawConfig), + currentEntries: ({ cfg }) => { + const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); + return account?.allowFrom ?? []; + }, + updatePrompt: ({ cfg }) => { + const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); + return Boolean(account?.allowedRoles?.length || account?.allowFrom?.length); + }, + setPolicy: ({ cfg, policy }) => setTwitchGroupPolicy(cfg as OpenClawConfig, policy), + resolveAllowlist: async () => [], + applyAllowlist: ({ cfg }) => cfg as OpenClawConfig, +}; + export const twitchSetupAdapter: ChannelSetupAdapter = { resolveAccountId: () => DEFAULT_ACCOUNT_ID, applyAccountConfig: ({ cfg }) => @@ -342,37 +379,10 @@ export const twitchSetupWizard: ChannelSetupWizard = { ? await twitchDmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter }) : cfgWithAccount; - if (!account?.allowFrom || account.allowFrom.length === 0) { - const accessConfig = await promptChannelAccessConfig({ - prompter, - label: "Twitch chat", - currentPolicy: account?.allowedRoles?.includes("all") - ? "open" - : account?.allowedRoles?.includes("moderator") - ? "allowlist" - : "disabled", - currentEntries: [], - placeholder: "", - updatePrompt: false, - }); - - if (accessConfig) { - const allowedRoles: TwitchRole[] = - accessConfig.policy === "open" - ? ["all"] - : accessConfig.policy === "allowlist" - ? ["moderator", "vip"] - : []; - - return { - cfg: setTwitchAccessControl(cfgWithAllowFrom, allowedRoles, true), - }; - } - } - return { cfg: cfgWithAllowFrom }; }, dmPolicy: twitchDmPolicy, + groupAccess: twitchGroupAccess, disable: (cfg) => { const twitch = (cfg.channels as Record)?.twitch as | Record diff --git a/src/channels/plugins/onboarding/channel-access-configure.test.ts b/src/channels/plugins/setup-group-access-configure.test.ts similarity index 77% rename from src/channels/plugins/onboarding/channel-access-configure.test.ts rename to src/channels/plugins/setup-group-access-configure.test.ts index aba8f05ea95..bb3b0307501 100644 --- a/src/channels/plugins/onboarding/channel-access-configure.test.ts +++ b/src/channels/plugins/setup-group-access-configure.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { configureChannelAccessWithAllowlist } from "./channel-access-configure.js"; -import type { ChannelAccessPolicy } from "./channel-access.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { configureChannelAccessWithAllowlist } from "./setup-group-access-configure.js"; +import type { ChannelAccessPolicy } from "./setup-group-access.js"; function createPrompter(params: { confirm: boolean; policy?: ChannelAccessPolicy; text?: string }) { return { @@ -89,6 +89,41 @@ describe("configureChannelAccessWithAllowlist", () => { expect(applyAllowlist).not.toHaveBeenCalled(); }); + it("supports allowlist policies without prompting for entries", async () => { + const cfg: OpenClawConfig = {}; + const prompter = createPrompter({ + confirm: true, + policy: "allowlist", + }); + const setPolicy = vi.fn( + (next: OpenClawConfig, policy: ChannelAccessPolicy): OpenClawConfig => ({ + ...next, + channels: { twitch: { groupPolicy: policy } }, + }), + ); + const resolveAllowlist = vi.fn(async () => ["ignored"]); + const applyAllowlist = vi.fn((params: { cfg: OpenClawConfig }) => params.cfg); + + const next = await configureChannelAccessWithAllowlist({ + cfg, + // oxlint-disable-next-line typescript/no-explicit-any + prompter: prompter as any, + label: "Twitch chat", + currentPolicy: "disabled", + currentEntries: [], + placeholder: "", + updatePrompt: false, + skipAllowlistEntries: true, + setPolicy, + resolveAllowlist, + applyAllowlist, + }); + + expect(next.channels).toEqual({ twitch: { groupPolicy: "allowlist" } }); + expect(resolveAllowlist).not.toHaveBeenCalled(); + expect(applyAllowlist).not.toHaveBeenCalled(); + }); + it("resolves allowlist entries and applies them after forcing allowlist policy", async () => { const cfg: OpenClawConfig = {}; const prompter = createPrompter({ diff --git a/src/channels/plugins/onboarding/channel-access-configure.ts b/src/channels/plugins/setup-group-access-configure.ts similarity index 65% rename from src/channels/plugins/onboarding/channel-access-configure.ts rename to src/channels/plugins/setup-group-access-configure.ts index 200efce5811..26b07f9cf99 100644 --- a/src/channels/plugins/onboarding/channel-access-configure.ts +++ b/src/channels/plugins/setup-group-access-configure.ts @@ -1,6 +1,6 @@ -import type { OpenClawConfig } from "../../../config/config.js"; -import type { WizardPrompter } from "../../../wizard/prompts.js"; -import { promptChannelAccessConfig, type ChannelAccessPolicy } from "./channel-access.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { WizardPrompter } from "../../wizard/prompts.js"; +import { promptChannelAccessConfig, type ChannelAccessPolicy } from "./setup-group-access.js"; export async function configureChannelAccessWithAllowlist(params: { cfg: OpenClawConfig; @@ -10,9 +10,10 @@ export async function configureChannelAccessWithAllowlist(params: { currentEntries: string[]; placeholder: string; updatePrompt: boolean; + skipAllowlistEntries?: boolean; setPolicy: (cfg: OpenClawConfig, policy: ChannelAccessPolicy) => OpenClawConfig; - resolveAllowlist: (params: { cfg: OpenClawConfig; entries: string[] }) => Promise; - applyAllowlist: (params: { cfg: OpenClawConfig; resolved: TResolved }) => OpenClawConfig; + resolveAllowlist?: (params: { cfg: OpenClawConfig; entries: string[] }) => Promise; + applyAllowlist?: (params: { cfg: OpenClawConfig; resolved: TResolved }) => OpenClawConfig; }): Promise { let next = params.cfg; const accessConfig = await promptChannelAccessConfig({ @@ -22,6 +23,7 @@ export async function configureChannelAccessWithAllowlist(params: { currentEntries: params.currentEntries, placeholder: params.placeholder, updatePrompt: params.updatePrompt, + skipAllowlistEntries: params.skipAllowlistEntries, }); if (!accessConfig) { return next; @@ -29,6 +31,9 @@ export async function configureChannelAccessWithAllowlist(params: { if (accessConfig.policy !== "allowlist") { return params.setPolicy(next, accessConfig.policy); } + if (params.skipAllowlistEntries || !params.resolveAllowlist || !params.applyAllowlist) { + return params.setPolicy(next, "allowlist"); + } const resolved = await params.resolveAllowlist({ cfg: next, entries: accessConfig.entries, diff --git a/src/channels/plugins/onboarding/channel-access.test.ts b/src/channels/plugins/setup-group-access.test.ts similarity index 84% rename from src/channels/plugins/onboarding/channel-access.test.ts rename to src/channels/plugins/setup-group-access.test.ts index 0e5b2ba6651..a19ed348015 100644 --- a/src/channels/plugins/onboarding/channel-access.test.ts +++ b/src/channels/plugins/setup-group-access.test.ts @@ -5,7 +5,7 @@ import { promptChannelAccessConfig, promptChannelAllowlist, promptChannelAccessPolicy, -} from "./channel-access.js"; +} from "./setup-group-access.js"; function createPrompter(params?: { confirm?: (options: { message: string; initialValue: boolean }) => Promise; @@ -83,6 +83,27 @@ describe("promptChannelAccessPolicy", () => { }); }); +describe("promptChannelAccessConfig", () => { + it("skips the allowlist text prompt when entries are policy-only", async () => { + const prompter = createPrompter({ + confirm: async () => true, + select: async () => "allowlist", + text: async () => { + throw new Error("text prompt should not run"); + }, + }); + + const result = await promptChannelAccessConfig({ + // oxlint-disable-next-line typescript/no-explicit-any + prompter: prompter as any, + label: "Twitch chat", + skipAllowlistEntries: true, + }); + + expect(result).toEqual({ policy: "allowlist", entries: [] }); + }); +}); + describe("promptChannelAccessConfig", () => { it("returns null when user skips configuration", async () => { const prompter = createPrompter({ diff --git a/src/channels/plugins/onboarding/channel-access.ts b/src/channels/plugins/setup-group-access.ts similarity index 92% rename from src/channels/plugins/onboarding/channel-access.ts rename to src/channels/plugins/setup-group-access.ts index ef86b37f336..a757816e9ec 100644 --- a/src/channels/plugins/onboarding/channel-access.ts +++ b/src/channels/plugins/setup-group-access.ts @@ -1,5 +1,5 @@ -import type { WizardPrompter } from "../../../wizard/prompts.js"; -import { splitOnboardingEntries } from "./helpers.js"; +import type { WizardPrompter } from "../../wizard/prompts.js"; +import { splitOnboardingEntries } from "./onboarding/helpers.js"; export type ChannelAccessPolicy = "allowlist" | "open" | "disabled"; @@ -64,6 +64,7 @@ export async function promptChannelAccessConfig(params: { placeholder?: string; allowOpen?: boolean; allowDisabled?: boolean; + skipAllowlistEntries?: boolean; defaultPrompt?: boolean; updatePrompt?: boolean; }): Promise<{ policy: ChannelAccessPolicy; entries: string[] } | null> { @@ -88,6 +89,9 @@ export async function promptChannelAccessConfig(params: { if (policy !== "allowlist") { return { policy, entries: [] }; } + if (params.skipAllowlistEntries) { + return { policy, entries: [] }; + } const entries = await promptChannelAllowlist({ prompter: params.prompter, label: params.label, diff --git a/src/channels/plugins/setup-wizard.ts b/src/channels/plugins/setup-wizard.ts index 9f4f1fdb5cc..2d4896dd733 100644 --- a/src/channels/plugins/setup-wizard.ts +++ b/src/channels/plugins/setup-wizard.ts @@ -8,14 +8,14 @@ import type { ChannelOnboardingStatus, ChannelOnboardingStatusContext, } from "./onboarding-types.js"; -import { configureChannelAccessWithAllowlist } from "./onboarding/channel-access-configure.js"; -import type { ChannelAccessPolicy } from "./onboarding/channel-access.js"; import { promptResolvedAllowFrom, resolveAccountIdForConfigure, runSingleChannelSecretStep, splitOnboardingEntries, } from "./onboarding/helpers.js"; +import { configureChannelAccessWithAllowlist } from "./setup-group-access-configure.js"; +import type { ChannelAccessPolicy } from "./setup-group-access.js"; import type { ChannelSetupInput } from "./types.core.js"; import type { ChannelPlugin } from "./types.js"; @@ -184,6 +184,7 @@ export type ChannelSetupWizardGroupAccess = { placeholder: string; helpTitle?: string; helpLines?: string[]; + skipAllowlistEntries?: boolean; currentPolicy: (params: { cfg: OpenClawConfig; accountId: string }) => ChannelAccessPolicy; currentEntries: (params: { cfg: OpenClawConfig; accountId: string }) => string[]; updatePrompt: (params: { cfg: OpenClawConfig; accountId: string }) => boolean; @@ -192,14 +193,14 @@ export type ChannelSetupWizardGroupAccess = { accountId: string; policy: ChannelAccessPolicy; }) => OpenClawConfig; - resolveAllowlist: (params: { + resolveAllowlist?: (params: { cfg: OpenClawConfig; accountId: string; credentialValues: ChannelSetupWizardCredentialValues; entries: string[]; prompter: Pick; }) => Promise; - applyAllowlist: (params: { + applyAllowlist?: (params: { cfg: OpenClawConfig; accountId: string; resolved: unknown; @@ -757,26 +758,31 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { currentEntries: access.currentEntries({ cfg: next, accountId }), placeholder: access.placeholder, updatePrompt: access.updatePrompt({ cfg: next, accountId }), + skipAllowlistEntries: access.skipAllowlistEntries, setPolicy: (currentCfg, policy) => access.setPolicy({ cfg: currentCfg, accountId, policy, }), - resolveAllowlist: async ({ cfg: currentCfg, entries }) => - await access.resolveAllowlist({ - cfg: currentCfg, - accountId, - credentialValues, - entries, - prompter, - }), - applyAllowlist: ({ cfg: currentCfg, resolved }) => - access.applyAllowlist({ - cfg: currentCfg, - accountId, - resolved, - }), + resolveAllowlist: access.resolveAllowlist + ? async ({ cfg: currentCfg, entries }) => + await access.resolveAllowlist!({ + cfg: currentCfg, + accountId, + credentialValues, + entries, + prompter, + }) + : undefined, + applyAllowlist: access.applyAllowlist + ? ({ cfg: currentCfg, resolved }) => + access.applyAllowlist!({ + cfg: currentCfg, + accountId, + resolved, + }) + : undefined, }); } diff --git a/src/plugin-sdk/googlechat.ts b/src/plugin-sdk/googlechat.ts index 464af58776b..42ad2eb032f 100644 --- a/src/plugin-sdk/googlechat.ts +++ b/src/plugin-sdk/googlechat.ts @@ -27,7 +27,6 @@ export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js export { addWildcardAllowFrom, mergeAllowFromEntries, - promptAccountId, splitOnboardingEntries, setTopLevelChannelDmPolicyWithAllowFrom, } from "../channels/plugins/onboarding/helpers.js"; diff --git a/src/plugin-sdk/irc.ts b/src/plugin-sdk/irc.ts index 472c46ea2e5..c74aab071ca 100644 --- a/src/plugin-sdk/irc.ts +++ b/src/plugin-sdk/irc.ts @@ -15,7 +15,6 @@ export { } from "../channels/plugins/helpers.js"; export { addWildcardAllowFrom, - promptAccountId, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, } from "../channels/plugins/onboarding/helpers.js"; diff --git a/src/plugin-sdk/tlon.ts b/src/plugin-sdk/tlon.ts index f1415103398..291834b9648 100644 --- a/src/plugin-sdk/tlon.ts +++ b/src/plugin-sdk/tlon.ts @@ -3,7 +3,6 @@ export type { ReplyPayload } from "../auto-reply/types.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { promptAccountId } from "../channels/plugins/onboarding/helpers.js"; export { applyAccountNameToChannelSection, patchScopedAccountConfig, diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index 307ea5f16f5..9f680ce6b0e 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -15,7 +15,6 @@ export { buildSingleChannelSecretPromptState, addWildcardAllowFrom, mergeAllowFromEntries, - promptAccountId, promptSingleChannelSecretInput, runSingleChannelSecretStep, setTopLevelChannelDmPolicyWithAllowFrom, diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index 3ad3ca47549..5dba9c0aa77 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -14,7 +14,6 @@ export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export { addWildcardAllowFrom, mergeAllowFromEntries, - promptAccountId, setTopLevelChannelDmPolicyWithAllowFrom, } from "../channels/plugins/onboarding/helpers.js"; export { From 46482a283a250aecbd45c5ef6f19e2a41e26effb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 19:52:28 -0700 Subject: [PATCH 150/943] feat: add nostr setup and unify channel setup discovery --- docs/channels/nostr.md | 9 + docs/cli/channels.md | 3 +- extensions/nostr/src/channel.ts | 3 + extensions/nostr/src/setup-surface.test.ts | 67 ++++ extensions/nostr/src/setup-surface.ts | 297 ++++++++++++++++++ scripts/lib/plugin-sdk-entries.mjs | 48 +-- scripts/lib/plugin-sdk-entrypoints.json | 45 +++ src/channels/plugins/types.core.ts | 2 + src/cli/channels-cli.ts | 4 + src/commands/channel-setup/discovery.ts | 108 +++++++ .../{onboarding => channel-setup}/registry.ts | 54 +++- src/commands/channel-test-helpers.ts | 2 +- src/commands/channels.add.test.ts | 96 ++++++ src/commands/channels/add.ts | 43 ++- src/commands/onboard-channels.e2e.test.ts | 121 +++++++ src/commands/onboard-channels.ts | 102 +++--- src/plugin-sdk/entrypoints.ts | 36 +++ src/plugin-sdk/index.test.ts | 2 +- src/plugin-sdk/nostr.ts | 2 + src/plugin-sdk/subpaths.test.ts | 8 +- 20 files changed, 922 insertions(+), 130 deletions(-) create mode 100644 extensions/nostr/src/setup-surface.test.ts create mode 100644 extensions/nostr/src/setup-surface.ts create mode 100644 scripts/lib/plugin-sdk-entrypoints.json create mode 100644 src/commands/channel-setup/discovery.ts rename src/commands/{onboarding => channel-setup}/registry.ts (54%) create mode 100644 src/plugin-sdk/entrypoints.ts diff --git a/docs/channels/nostr.md b/docs/channels/nostr.md index 3368933d6c4..760704b589f 100644 --- a/docs/channels/nostr.md +++ b/docs/channels/nostr.md @@ -40,6 +40,15 @@ openclaw plugins install --link /extensions/nostr Restart the Gateway after installing or enabling plugins. +### Non-interactive setup + +```bash +openclaw channels add --channel nostr --private-key "$NOSTR_PRIVATE_KEY" +openclaw channels add --channel nostr --private-key "$NOSTR_PRIVATE_KEY" --relay-urls "wss://relay.damus.io,wss://relay.primal.net" +``` + +Use `--use-env` to keep `NOSTR_PRIVATE_KEY` in the environment instead of storing the key in config. + ## Quick setup 1. Generate a Nostr keypair (if needed): diff --git a/docs/cli/channels.md b/docs/cli/channels.md index 654fbef5fa9..96b9ef33f8c 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -30,10 +30,11 @@ openclaw channels logs --channel all ```bash openclaw channels add --channel telegram --token +openclaw channels add --channel nostr --private-key "$NOSTR_PRIVATE_KEY" openclaw channels remove --channel telegram --delete ``` -Tip: `openclaw channels add --help` shows per-channel flags (token, app token, signal-cli paths, etc). +Tip: `openclaw channels add --help` shows per-channel flags (token, private key, app token, signal-cli paths, etc). When you run `openclaw channels add` without flags, the interactive wizard can prompt: diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 937c698bd47..21dfce3a9da 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -17,6 +17,7 @@ import type { MetricEvent, MetricsSnapshot } from "./metrics.js"; import { normalizePubkey, startNostrBus, type NostrBusHandle } from "./nostr-bus.js"; import type { ProfilePublishResult } from "./nostr-profile.js"; import { getNostrRuntime } from "./runtime.js"; +import { nostrSetupAdapter, nostrSetupWizard } from "./setup-surface.js"; import { listNostrAccountIds, resolveDefaultNostrAccountId, @@ -47,6 +48,8 @@ export const nostrPlugin: ChannelPlugin = { }, reload: { configPrefixes: ["channels.nostr"] }, configSchema: buildChannelConfigSchema(NostrConfigSchema), + setup: nostrSetupAdapter, + setupWizard: nostrSetupWizard, config: { listAccountIds: (cfg) => listNostrAccountIds(cfg), diff --git a/extensions/nostr/src/setup-surface.test.ts b/extensions/nostr/src/setup-surface.test.ts new file mode 100644 index 00000000000..c9c62e14c9a --- /dev/null +++ b/extensions/nostr/src/setup-surface.test.ts @@ -0,0 +1,67 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/nostr"; +import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { nostrPlugin } from "./channel.js"; + +function createPrompter(overrides: Partial): WizardPrompter { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: vi.fn(async ({ options }: { options: Array<{ value: string }> }) => { + const first = options[0]; + if (!first) { + throw new Error("no options"); + } + return first.value; + }) as WizardPrompter["select"], + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} + +const nostrConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: nostrPlugin, + wizard: nostrPlugin.setupWizard!, +}); + +describe("nostr setup wizard", () => { + it("configures a private key and relay URLs", async () => { + const prompter = createPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Nostr private key (nsec... or hex)") { + return "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + } + if (message === "Relay URLs (comma-separated, optional)") { + return "wss://relay.damus.io, wss://relay.primal.net"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + }); + + const result = await nostrConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime: createRuntimeEnv(), + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.nostr?.enabled).toBe(true); + expect(result.cfg.channels?.nostr?.privateKey).toBe( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ); + expect(result.cfg.channels?.nostr?.relays).toEqual([ + "wss://relay.damus.io", + "wss://relay.primal.net", + ]); + }); +}); diff --git a/extensions/nostr/src/setup-surface.ts b/extensions/nostr/src/setup-surface.ts new file mode 100644 index 00000000000..d58a4c4fbdc --- /dev/null +++ b/extensions/nostr/src/setup-surface.ts @@ -0,0 +1,297 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + mergeAllowFromEntries, + parseOnboardingEntriesWithParser, + setTopLevelChannelAllowFrom, + setTopLevelChannelDmPolicyWithAllowFrom, + splitOnboardingEntries, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DmPolicy } from "../../../src/config/types.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { DEFAULT_RELAYS, getPublicKeyFromPrivate, normalizePubkey } from "./nostr-bus.js"; +import { resolveNostrAccount } from "./types.js"; + +const channel = "nostr" as const; + +const NOSTR_SETUP_HELP_LINES = [ + "Use a Nostr private key in nsec or 64-character hex format.", + "Relay URLs are optional. Leave blank to keep the default relay set.", + "Env vars supported: NOSTR_PRIVATE_KEY (default account only).", + `Docs: ${formatDocsLink("/channels/nostr", "channels/nostr")}`, +]; + +const NOSTR_ALLOW_FROM_HELP_LINES = [ + "Allowlist Nostr DMs by npub or hex pubkey.", + "Examples:", + "- npub1...", + "- nostr:npub1...", + "- 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/channels/nostr", "channels/nostr")}`, +]; + +function patchNostrConfig(params: { + cfg: OpenClawConfig; + patch: Record; + clearFields?: string[]; + enabled?: boolean; +}): OpenClawConfig { + const existing = (params.cfg.channels?.nostr ?? {}) as Record; + const nextNostr = { ...existing }; + for (const field of params.clearFields ?? []) { + delete nextNostr[field]; + } + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + nostr: { + ...nextNostr, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }, + }, + }; +} + +function setNostrDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { + return setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy, + }); +} + +function setNostrAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { + return setTopLevelChannelAllowFrom({ + cfg, + channel, + allowFrom, + }); +} + +function parseRelayUrls(raw: string): { relays: string[]; error?: string } { + const entries = splitOnboardingEntries(raw); + const relays: string[] = []; + for (const entry of entries) { + try { + const parsed = new URL(entry); + if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") { + return { relays: [], error: `Relay must use ws:// or wss:// (${entry})` }; + } + } catch { + return { relays: [], error: `Invalid relay URL: ${entry}` }; + } + relays.push(entry); + } + return { relays: [...new Set(relays)] }; +} + +function parseNostrAllowFrom(raw: string): { entries: string[]; error?: string } { + return parseOnboardingEntriesWithParser(raw, (entry) => { + const cleaned = entry.replace(/^nostr:/i, "").trim(); + try { + return { value: normalizePubkey(cleaned) }; + } catch { + return { error: `Invalid Nostr pubkey: ${entry}` }; + } + }); +} + +async function promptNostrAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; +}): Promise { + const existing = params.cfg.channels?.nostr?.allowFrom ?? []; + await params.prompter.note(NOSTR_ALLOW_FROM_HELP_LINES.join("\n"), "Nostr allowlist"); + const entry = await params.prompter.text({ + message: "Nostr allowFrom", + placeholder: "npub1..., 0123abcd...", + initialValue: existing[0] ? String(existing[0]) : undefined, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + return parseNostrAllowFrom(raw).error; + }, + }); + const parsed = parseNostrAllowFrom(String(entry)); + return setNostrAllowFrom(params.cfg, mergeAllowFromEntries(existing, parsed.entries)); +} + +const nostrDmPolicy: ChannelOnboardingDmPolicy = { + label: "Nostr", + channel, + policyKey: "channels.nostr.dmPolicy", + allowFromKey: "channels.nostr.allowFrom", + getCurrent: (cfg) => cfg.channels?.nostr?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setNostrDmPolicy(cfg, policy), + promptAllowFrom: promptNostrAllowFrom, +}; + +export const nostrSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountName: ({ cfg, name }) => + patchNostrConfig({ + cfg, + patch: name?.trim() ? { name: name.trim() } : {}, + }), + validateInput: ({ input }) => { + const typedInput = input as { + useEnv?: boolean; + privateKey?: string; + relayUrls?: string; + }; + if (!typedInput.useEnv) { + const privateKey = typedInput.privateKey?.trim(); + if (!privateKey) { + return "Nostr requires --private-key or --use-env."; + } + try { + getPublicKeyFromPrivate(privateKey); + } catch { + return "Nostr private key must be valid nsec or 64-character hex."; + } + } + if (typedInput.relayUrls?.trim()) { + return parseRelayUrls(typedInput.relayUrls).error ?? null; + } + return null; + }, + applyAccountConfig: ({ cfg, input }) => { + const typedInput = input as { + useEnv?: boolean; + privateKey?: string; + relayUrls?: string; + }; + const relayResult = typedInput.relayUrls?.trim() + ? parseRelayUrls(typedInput.relayUrls) + : { relays: [] }; + return patchNostrConfig({ + cfg, + enabled: true, + clearFields: typedInput.useEnv ? ["privateKey"] : undefined, + patch: { + ...(typedInput.useEnv ? {} : { privateKey: typedInput.privateKey?.trim() }), + ...(relayResult.relays.length > 0 ? { relays: relayResult.relays } : {}), + }, + }); + }, +}; + +export const nostrSetupWizard: ChannelSetupWizard = { + channel, + resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID, + resolveShouldPromptAccountIds: () => false, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs private key", + configuredHint: "configured", + unconfiguredHint: "needs private key", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => resolveNostrAccount({ cfg }).configured, + resolveStatusLines: ({ cfg, configured }) => { + const account = resolveNostrAccount({ cfg }); + return [ + `Nostr: ${configured ? "configured" : "needs private key"}`, + `Relays: ${account.relays.length || DEFAULT_RELAYS.length}`, + ]; + }, + }, + introNote: { + title: "Nostr setup", + lines: NOSTR_SETUP_HELP_LINES, + }, + envShortcut: { + prompt: "NOSTR_PRIVATE_KEY detected. Use env var?", + preferredEnvVar: "NOSTR_PRIVATE_KEY", + isAvailable: ({ cfg, accountId }) => + accountId === DEFAULT_ACCOUNT_ID && + Boolean(process.env.NOSTR_PRIVATE_KEY?.trim()) && + !resolveNostrAccount({ cfg, accountId }).config.privateKey?.trim(), + apply: async ({ cfg }) => + patchNostrConfig({ + cfg, + enabled: true, + clearFields: ["privateKey"], + patch: {}, + }), + }, + credentials: [ + { + inputKey: "privateKey", + providerHint: channel, + credentialLabel: "private key", + preferredEnvVar: "NOSTR_PRIVATE_KEY", + helpTitle: "Nostr private key", + helpLines: NOSTR_SETUP_HELP_LINES, + envPrompt: "NOSTR_PRIVATE_KEY detected. Use env var?", + keepPrompt: "Nostr private key already configured. Keep it?", + inputPrompt: "Nostr private key (nsec... or hex)", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const account = resolveNostrAccount({ cfg, accountId }); + return { + accountConfigured: account.configured, + hasConfiguredValue: Boolean(account.config.privateKey?.trim()), + resolvedValue: account.config.privateKey?.trim(), + envValue: process.env.NOSTR_PRIVATE_KEY?.trim(), + }; + }, + applyUseEnv: async ({ cfg }) => + patchNostrConfig({ + cfg, + enabled: true, + clearFields: ["privateKey"], + patch: {}, + }), + applySet: async ({ cfg, resolvedValue }) => + patchNostrConfig({ + cfg, + enabled: true, + patch: { privateKey: resolvedValue }, + }), + }, + ], + textInputs: [ + { + inputKey: "relayUrls", + message: "Relay URLs (comma-separated, optional)", + placeholder: DEFAULT_RELAYS.join(", "), + required: false, + applyEmptyValue: true, + helpTitle: "Nostr relays", + helpLines: ["Use ws:// or wss:// relay URLs.", "Leave blank to keep the default relay set."], + currentValue: ({ cfg, accountId }) => { + const account = resolveNostrAccount({ cfg, accountId }); + const relays = + cfg.channels?.nostr?.relays && cfg.channels.nostr.relays.length > 0 ? account.relays : []; + return relays.join(", "); + }, + keepPrompt: (value) => `Relay URLs set (${value}). Keep them?`, + validate: ({ value }) => parseRelayUrls(value).error, + applySet: async ({ cfg, value }) => { + const relayResult = parseRelayUrls(value); + return patchNostrConfig({ + cfg, + enabled: true, + clearFields: relayResult.relays.length > 0 ? undefined : ["relays"], + patch: relayResult.relays.length > 0 ? { relays: relayResult.relays } : {}, + }); + }, + }, + ], + dmPolicy: nostrDmPolicy, + disable: (cfg) => + patchNostrConfig({ + cfg, + patch: { enabled: false }, + }), +}; diff --git a/scripts/lib/plugin-sdk-entries.mjs b/scripts/lib/plugin-sdk-entries.mjs index ba6c1a5c386..c2ce28484ae 100644 --- a/scripts/lib/plugin-sdk-entries.mjs +++ b/scripts/lib/plugin-sdk-entries.mjs @@ -1,48 +1,6 @@ -export const pluginSdkEntrypoints = [ - "index", - "core", - "compat", - "telegram", - "discord", - "slack", - "signal", - "imessage", - "whatsapp", - "line", - "msteams", - "acpx", - "bluebubbles", - "copilot-proxy", - "device-pair", - "diagnostics-otel", - "diffs", - "feishu", - "googlechat", - "irc", - "llm-task", - "lobster", - "matrix", - "mattermost", - "memory-core", - "memory-lancedb", - "minimax-portal-auth", - "nextcloud-talk", - "nostr", - "open-prose", - "phone-control", - "qwen-portal-auth", - "synology-chat", - "talk-voice", - "test-utils", - "thread-ownership", - "tlon", - "twitch", - "voice-call", - "zalo", - "zalouser", - "account-id", - "keyed-async-queue", -]; +import pluginSdkEntryList from "./plugin-sdk-entrypoints.json" with { type: "json" }; + +export const pluginSdkEntrypoints = [...pluginSdkEntryList]; export const pluginSdkSubpaths = pluginSdkEntrypoints.filter((entry) => entry !== "index"); diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json new file mode 100644 index 00000000000..c42f27db5a1 --- /dev/null +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -0,0 +1,45 @@ +[ + "index", + "core", + "compat", + "telegram", + "discord", + "slack", + "signal", + "imessage", + "whatsapp", + "line", + "msteams", + "acpx", + "bluebubbles", + "copilot-proxy", + "device-pair", + "diagnostics-otel", + "diffs", + "feishu", + "googlechat", + "irc", + "llm-task", + "lobster", + "matrix", + "mattermost", + "memory-core", + "memory-lancedb", + "minimax-portal-auth", + "nextcloud-talk", + "nostr", + "open-prose", + "phone-control", + "qwen-portal-auth", + "synology-chat", + "talk-voice", + "test-utils", + "thread-ownership", + "tlon", + "twitch", + "voice-call", + "zalo", + "zalouser", + "account-id", + "keyed-async-queue" +] diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index fef8b010ca5..73600e47d5b 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -21,6 +21,7 @@ export type ChannelAgentToolFactory = (params: { cfg?: OpenClawConfig }) => Chan export type ChannelSetupInput = { name?: string; token?: string; + privateKey?: string; tokenFile?: string; botToken?: string; appToken?: string; @@ -46,6 +47,7 @@ export type ChannelSetupInput = { initialSyncLimit?: number; ship?: string; url?: string; + relayUrls?: string; code?: string; groupChannels?: string[]; dmAllowlist?: string[]; diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index 3015ed1d42a..d2e7bf148f3 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -14,6 +14,7 @@ const optionNamesAdd = [ "account", "name", "token", + "privateKey", "tokenFile", "botToken", "appToken", @@ -39,6 +40,7 @@ const optionNamesAdd = [ "initialSyncLimit", "ship", "url", + "relayUrls", "code", "groupChannels", "dmAllowlist", @@ -164,6 +166,7 @@ export function registerChannelsCli(program: Command) { .option("--account ", "Account id (default when omitted)") .option("--name ", "Display name for this account") .option("--token ", "Bot token (Telegram/Discord)") + .option("--private-key ", "Nostr private key (nsec... or hex)") .option("--token-file ", "Bot token file (Telegram)") .option("--bot-token ", "Slack bot token (xoxb-...)") .option("--app-token ", "Slack app token (xapp-...)") @@ -188,6 +191,7 @@ export function registerChannelsCli(program: Command) { .option("--initial-sync-limit ", "Matrix initial sync limit") .option("--ship ", "Tlon ship name (~sampel-palnet)") .option("--url ", "Tlon ship URL") + .option("--relay-urls ", "Nostr relay URLs (comma-separated)") .option("--code ", "Tlon login code") .option("--group-channels ", "Tlon group channels (comma-separated)") .option("--dm-allowlist ", "Tlon DM allowlist (comma-separated ships)") diff --git a/src/commands/channel-setup/discovery.ts b/src/commands/channel-setup/discovery.ts new file mode 100644 index 00000000000..8ae5f16f800 --- /dev/null +++ b/src/commands/channel-setup/discovery.ts @@ -0,0 +1,108 @@ +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { + listChannelPluginCatalogEntries, + type ChannelPluginCatalogEntry, +} from "../../channels/plugins/catalog.js"; +import type { ChannelMeta, ChannelPlugin } from "../../channels/plugins/types.js"; +import { listChatChannels } from "../../channels/registry.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js"; +import type { ChannelChoice } from "../onboard-types.js"; + +type ChannelCatalogEntry = { + id: ChannelChoice; + meta: ChannelMeta; +}; + +export type ResolvedChannelSetupEntries = { + entries: ChannelCatalogEntry[]; + installedCatalogEntries: ChannelPluginCatalogEntry[]; + installableCatalogEntries: ChannelPluginCatalogEntry[]; + installedCatalogById: Map; + installableCatalogById: Map; +}; + +function resolveWorkspaceDir(cfg: OpenClawConfig, workspaceDir?: string): string | undefined { + return workspaceDir ?? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); +} + +export function listManifestInstalledChannelIds(params: { + cfg: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): Set { + const workspaceDir = resolveWorkspaceDir(params.cfg, params.workspaceDir); + return new Set( + loadPluginManifestRegistry({ + config: params.cfg, + workspaceDir, + env: params.env ?? process.env, + }).plugins.flatMap((plugin) => plugin.channels as ChannelChoice[]), + ); +} + +export function isCatalogChannelInstalled(params: { + cfg: OpenClawConfig; + entry: ChannelPluginCatalogEntry; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): boolean { + return listManifestInstalledChannelIds(params).has(params.entry.id as ChannelChoice); +} + +export function resolveChannelSetupEntries(params: { + cfg: OpenClawConfig; + installedPlugins: ChannelPlugin[]; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): ResolvedChannelSetupEntries { + const workspaceDir = resolveWorkspaceDir(params.cfg, params.workspaceDir); + const manifestInstalledIds = listManifestInstalledChannelIds({ + cfg: params.cfg, + workspaceDir, + env: params.env, + }); + const installedPluginIds = new Set(params.installedPlugins.map((plugin) => plugin.id)); + const catalogEntries = listChannelPluginCatalogEntries({ workspaceDir }); + const installedCatalogEntries = catalogEntries.filter( + (entry) => + !installedPluginIds.has(entry.id) && manifestInstalledIds.has(entry.id as ChannelChoice), + ); + const installableCatalogEntries = catalogEntries.filter( + (entry) => + !installedPluginIds.has(entry.id) && !manifestInstalledIds.has(entry.id as ChannelChoice), + ); + + const metaById = new Map(); + for (const meta of listChatChannels()) { + metaById.set(meta.id, meta); + } + for (const plugin of params.installedPlugins) { + metaById.set(plugin.id, plugin.meta); + } + for (const entry of installedCatalogEntries) { + if (!metaById.has(entry.id)) { + metaById.set(entry.id, entry.meta); + } + } + for (const entry of installableCatalogEntries) { + if (!metaById.has(entry.id)) { + metaById.set(entry.id, entry.meta); + } + } + + return { + entries: Array.from(metaById, ([id, meta]) => ({ + id: id as ChannelChoice, + meta, + })), + installedCatalogEntries, + installableCatalogEntries, + installedCatalogById: new Map( + installedCatalogEntries.map((entry) => [entry.id as ChannelChoice, entry]), + ), + installableCatalogById: new Map( + installableCatalogEntries.map((entry) => [entry.id as ChannelChoice, entry]), + ), + }; +} diff --git a/src/commands/onboarding/registry.ts b/src/commands/channel-setup/registry.ts similarity index 54% rename from src/commands/onboarding/registry.ts rename to src/commands/channel-setup/registry.ts index 9d7711e3092..576d7e14b60 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/channel-setup/registry.ts @@ -1,8 +1,29 @@ +import { discordPlugin } from "../../../extensions/discord/src/channel.js"; +import { googlechatPlugin } from "../../../extensions/googlechat/src/channel.js"; +import { imessagePlugin } from "../../../extensions/imessage/src/channel.js"; +import { ircPlugin } from "../../../extensions/irc/src/channel.js"; +import { linePlugin } from "../../../extensions/line/src/channel.js"; +import { signalPlugin } from "../../../extensions/signal/src/channel.js"; +import { slackPlugin } from "../../../extensions/slack/src/channel.js"; +import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; +import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js"; import { listChannelSetupPlugins } from "../../channels/plugins/setup-registry.js"; import { buildChannelOnboardingAdapterFromSetupWizard } from "../../channels/plugins/setup-wizard.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { ChannelChoice } from "../onboard-types.js"; -import type { ChannelOnboardingAdapter } from "./types.js"; +import type { ChannelOnboardingAdapter } from "../onboarding/types.js"; + +const EMPTY_REGISTRY_FALLBACK_PLUGINS = [ + telegramPlugin, + whatsappPlugin, + discordPlugin, + ircPlugin, + googlechatPlugin, + slackPlugin, + signalPlugin, + imessagePlugin, + linePlugin, +]; const setupWizardAdapters = new WeakMap(); @@ -26,7 +47,12 @@ export function resolveChannelOnboardingAdapterForPlugin( const CHANNEL_ONBOARDING_ADAPTERS = () => { const adapters = new Map(); - for (const plugin of listChannelSetupPlugins()) { + const setupPlugins = listChannelSetupPlugins(); + const plugins = + setupPlugins.length > 0 + ? setupPlugins + : (EMPTY_REGISTRY_FALLBACK_PLUGINS as unknown as ReturnType); + for (const plugin of plugins) { const adapter = resolveChannelOnboardingAdapterForPlugin(plugin); if (!adapter) { continue; @@ -51,23 +77,23 @@ export async function loadBundledChannelOnboardingPlugin( ): Promise { switch (channel) { case "discord": - return (await import("../../../extensions/discord/setup-entry.js")).default - .plugin as ChannelPlugin; + return discordPlugin as ChannelPlugin; + case "googlechat": + return googlechatPlugin as ChannelPlugin; case "imessage": - return (await import("../../../extensions/imessage/setup-entry.js")).default - .plugin as ChannelPlugin; + return imessagePlugin as ChannelPlugin; + case "irc": + return ircPlugin as ChannelPlugin; + case "line": + return linePlugin as ChannelPlugin; case "signal": - return (await import("../../../extensions/signal/setup-entry.js")).default - .plugin as ChannelPlugin; + return signalPlugin as ChannelPlugin; case "slack": - return (await import("../../../extensions/slack/setup-entry.js")).default - .plugin as ChannelPlugin; + return slackPlugin as ChannelPlugin; case "telegram": - return (await import("../../../extensions/telegram/setup-entry.js")).default - .plugin as ChannelPlugin; + return telegramPlugin as ChannelPlugin; case "whatsapp": - return (await import("../../../extensions/whatsapp/setup-entry.js")).default - .plugin as ChannelPlugin; + return whatsappPlugin as ChannelPlugin; default: return undefined; } diff --git a/src/commands/channel-test-helpers.ts b/src/commands/channel-test-helpers.ts index 2814f6bb5bd..97167228e7f 100644 --- a/src/commands/channel-test-helpers.ts +++ b/src/commands/channel-test-helpers.ts @@ -6,8 +6,8 @@ import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { getChannelOnboardingAdapter } from "./channel-setup/registry.js"; import type { ChannelChoice } from "./onboard-types.js"; -import { getChannelOnboardingAdapter } from "./onboarding/registry.js"; import type { ChannelOnboardingAdapter } from "./onboarding/types.js"; type ChannelOnboardingAdapterPatch = Partial< diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index 9f584494fba..fdb3e61f97d 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -14,6 +14,10 @@ const catalogMocks = vi.hoisted(() => ({ listChannelPluginCatalogEntries: vi.fn((): ChannelPluginCatalogEntry[] => []), })); +const manifestRegistryMocks = vi.hoisted(() => ({ + loadPluginManifestRegistry: vi.fn(() => ({ plugins: [], diagnostics: [] })), +})); + vi.mock("../channels/plugins/catalog.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -22,6 +26,14 @@ vi.mock("../channels/plugins/catalog.js", async (importOriginal) => { }; }); +vi.mock("../plugins/manifest-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadPluginManifestRegistry: manifestRegistryMocks.loadPluginManifestRegistry, + }; +}); + vi.mock("./onboarding/plugin-install.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -48,6 +60,11 @@ describe("channelsAddCommand", () => { runtime.exit.mockClear(); catalogMocks.listChannelPluginCatalogEntries.mockClear(); catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([]); + manifestRegistryMocks.loadPluginManifestRegistry.mockClear(); + manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [], + diagnostics: [], + }); vi.mocked(ensureOnboardingPluginInstalled).mockClear(); vi.mocked(ensureOnboardingPluginInstalled).mockImplementation(async ({ cfg }) => ({ cfg, @@ -171,6 +188,85 @@ describe("channelsAddCommand", () => { expect(runtime.exit).not.toHaveBeenCalled(); }); + it("uses the installed external channel snapshot without reinstalling", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); + setActivePluginRegistry(createTestRegistry()); + const catalogEntry: ChannelPluginCatalogEntry = { + id: "msteams", + pluginId: "@openclaw/msteams-plugin", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams", + docsPath: "/channels/msteams", + blurb: "teams channel", + }, + install: { + npmSpec: "@openclaw/msteams", + }, + }; + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]); + manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "@openclaw/msteams-plugin", + channels: ["msteams"], + } as never, + ], + diagnostics: [], + }); + const scopedMSTeamsPlugin = { + ...createChannelTestPluginBase({ + id: "msteams", + label: "Microsoft Teams", + docsPath: "/channels/msteams", + }), + setup: { + applyAccountConfig: vi.fn(({ cfg, input }) => ({ + ...cfg, + channels: { + ...cfg.channels, + msteams: { + enabled: true, + tenantId: input.token, + }, + }, + })), + }, + }; + vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockReturnValue( + createTestRegistry([{ pluginId: "msteams", plugin: scopedMSTeamsPlugin, source: "test" }]), + ); + + await channelsAddCommand( + { + channel: "msteams", + account: "default", + token: "tenant-installed", + }, + runtime, + { hasFlags: true }, + ); + + expect(ensureOnboardingPluginInstalled).not.toHaveBeenCalled(); + expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "msteams", + pluginId: "@openclaw/msteams-plugin", + }), + ); + expect(configMocks.writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + channels: { + msteams: { + enabled: true, + tenantId: "tenant-installed", + }, + }, + }), + ); + }); + it("uses the installed plugin id when channel and plugin ids differ", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); setActivePluginRegistry(createTestRegistry()); diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 88e1a245906..0c9b5b15e56 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -9,6 +9,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-ke import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; import { applyAgentBindings, describeBinding } from "../agents.bindings.js"; +import { isCatalogChannelInstalled } from "../channel-setup/discovery.js"; import type { ChannelChoice } from "../onboard-types.js"; import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js"; import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js"; @@ -202,24 +203,32 @@ export async function channelsAddCommand( }; if (!channel && catalogEntry) { - const { ensureOnboardingPluginInstalled } = await import("../onboarding/plugin-install.js"); - const prompter = createClackPrompter(); const workspaceDir = resolveWorkspaceDir(); - const result = await ensureOnboardingPluginInstalled({ - cfg: nextConfig, - entry: catalogEntry, - prompter, - runtime, - workspaceDir, - }); - nextConfig = result.cfg; - if (!result.installed) { - return; + if ( + !isCatalogChannelInstalled({ + cfg: nextConfig, + entry: catalogEntry, + workspaceDir, + }) + ) { + const { ensureOnboardingPluginInstalled } = await import("../onboarding/plugin-install.js"); + const prompter = createClackPrompter(); + const result = await ensureOnboardingPluginInstalled({ + cfg: nextConfig, + entry: catalogEntry, + prompter, + runtime, + workspaceDir, + }); + nextConfig = result.cfg; + if (!result.installed) { + return; + } + catalogEntry = { + ...catalogEntry, + ...(result.pluginId ? { pluginId: result.pluginId } : {}), + }; } - catalogEntry = { - ...catalogEntry, - ...(result.pluginId ? { pluginId: result.pluginId } : {}), - }; channel = normalizeChannelId(catalogEntry.id) ?? (catalogEntry.id as ChannelId); } @@ -251,6 +260,7 @@ export async function channelsAddCommand( const input: ChannelSetupInput = { name: opts.name, token: opts.token, + privateKey: opts.privateKey, tokenFile: opts.tokenFile, botToken: opts.botToken, appToken: opts.appToken, @@ -276,6 +286,7 @@ export async function channelsAddCommand( useEnv, ship: opts.ship, url: opts.url, + relayUrls: opts.relayUrls, code: opts.code, groupChannels, dmAllowlist, diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index c469f50a54e..0f2fb4c2e1e 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -10,6 +10,7 @@ import { } from "./channel-test-helpers.js"; import { setupChannels } from "./onboard-channels.js"; import { + ensureOnboardingPluginInstalled, loadOnboardingPluginRegistrySnapshotForChannel, reloadOnboardingPluginRegistry, } from "./onboarding/plugin-install.js"; @@ -19,6 +20,10 @@ const catalogMocks = vi.hoisted(() => ({ listChannelPluginCatalogEntries: vi.fn(), })); +const manifestRegistryMocks = vi.hoisted(() => ({ + loadPluginManifestRegistry: vi.fn(() => ({ plugins: [], diagnostics: [] })), +})); + function createPrompter(overrides: Partial): WizardPrompter { return createWizardPrompter( { @@ -197,6 +202,14 @@ vi.mock("../channels/plugins/catalog.js", async (importOriginal) => { }; }); +vi.mock("../plugins/manifest-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadPluginManifestRegistry: manifestRegistryMocks.loadPluginManifestRegistry, + }; +}); + vi.mock("./onboard-helpers.js", () => ({ detectBinary: vi.fn(async () => false), })); @@ -205,6 +218,10 @@ vi.mock("./onboarding/plugin-install.js", async (importOriginal) => { const actual = await importOriginal(); return { ...(actual as Record), + ensureOnboardingPluginInstalled: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ + cfg, + installed: true, + })), // Allow tests to simulate an empty plugin registry during onboarding. loadOnboardingPluginRegistrySnapshotForChannel: vi.fn(() => createEmptyPluginRegistry()), reloadOnboardingPluginRegistry: vi.fn(() => {}), @@ -215,6 +232,16 @@ describe("setupChannels", () => { beforeEach(() => { setDefaultChannelPluginRegistryForTests(); catalogMocks.listChannelPluginCatalogEntries.mockReset(); + manifestRegistryMocks.loadPluginManifestRegistry.mockReset(); + manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [], + diagnostics: [], + }); + vi.mocked(ensureOnboardingPluginInstalled).mockClear(); + vi.mocked(ensureOnboardingPluginInstalled).mockImplementation(async ({ cfg }) => ({ + cfg, + installed: true, + })); vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockClear(); vi.mocked(reloadOnboardingPluginRegistry).mockClear(); }); @@ -404,6 +431,100 @@ describe("setupChannels", () => { expect(multiselect).not.toHaveBeenCalled(); }); + it("treats installed external plugin channels as installed without reinstall prompts", async () => { + setActivePluginRegistry(createEmptyPluginRegistry()); + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "msteams", + pluginId: "@openclaw/msteams-plugin", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams", + docsPath: "/channels/msteams", + blurb: "teams channel", + }, + install: { + npmSpec: "@openclaw/msteams", + }, + } satisfies ChannelPluginCatalogEntry, + ]); + manifestRegistryMocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "@openclaw/msteams-plugin", + channels: ["msteams"], + } as never, + ], + diagnostics: [], + }); + vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockImplementation( + ({ channel }: { channel: string }) => { + const registry = createEmptyPluginRegistry(); + if (channel === "msteams") { + registry.channelSetups.push({ + pluginId: "@openclaw/msteams-plugin", + source: "test", + plugin: { + id: "msteams", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams", + docsPath: "/channels/msteams", + blurb: "teams channel", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + setupWizard: { + channel: "msteams", + status: { + configuredLabel: "configured", + unconfiguredLabel: "installed", + resolveConfigured: () => false, + resolveStatusLines: async () => [], + resolveSelectionHint: async () => "installed", + }, + credentials: [], + }, + outbound: { deliveryMode: "direct" }, + }, + } as never); + } + return registry; + }, + ); + + let channelSelectionCount = 0; + const select = vi.fn(async ({ message }: { message: string }) => { + if (message === "Select a channel") { + channelSelectionCount += 1; + return channelSelectionCount === 1 ? "msteams" : "__done__"; + } + return "__done__"; + }); + const { multiselect, text } = createUnexpectedPromptGuards(); + const prompter = createPrompter({ + select: select as unknown as WizardPrompter["select"], + multiselect, + text, + }); + + await runSetupChannels({} as OpenClawConfig, prompter); + + expect(ensureOnboardingPluginInstalled).not.toHaveBeenCalled(); + expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "msteams", + pluginId: "@openclaw/msteams-plugin", + }), + ); + expect(multiselect).not.toHaveBeenCalled(); + }); + it("uses scoped plugin accounts when disabling a configured external channel", async () => { setActivePluginRegistry(createEmptyPluginRegistry()); const setAccountEnabled = vi.fn( diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index c70fbde04ab..4fa8807d55e 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -5,7 +5,8 @@ import { getChannelSetupPlugin, listChannelSetupPlugins, } from "../channels/plugins/setup-registry.js"; -import type { ChannelMeta, ChannelPlugin } from "../channels/plugins/types.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; import { formatChannelPrimerLine, formatChannelSelectionLine, @@ -16,11 +17,11 @@ import type { OpenClawConfig } from "../config/config.js"; import { isChannelConfigured } from "../config/plugin-auto-enable.js"; import type { DmPolicy } from "../config/types.js"; import { enablePluginInConfig } from "../plugins/enable.js"; -import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; +import { resolveChannelSetupEntries } from "./channel-setup/discovery.js"; import type { ChannelChoice } from "./onboard-types.js"; import { ensureOnboardingPluginInstalled, @@ -29,7 +30,7 @@ import { import { loadBundledChannelOnboardingPlugin, resolveChannelOnboardingAdapterForPlugin, -} from "./onboarding/registry.js"; +} from "./channel-setup/registry.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingConfiguredResult, @@ -44,6 +45,7 @@ type ConfiguredChannelAction = "update" | "disable" | "delete" | "skip"; type ChannelStatusSummary = { installedPlugins: ReturnType; catalogEntries: ReturnType; + installedCatalogEntries: ReturnType; statusByChannel: Map; statusLines: string[]; }; @@ -125,15 +127,11 @@ async function collectChannelStatus(params: { }): Promise { const installedPlugins = params.installedPlugins ?? listChannelSetupPlugins(); const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); - const allCatalogEntries = listChannelPluginCatalogEntries({ workspaceDir }); - const installedChannelIds = new Set( - loadPluginManifestRegistry({ - config: params.cfg, - workspaceDir, - env: process.env, - }).plugins.flatMap((plugin) => plugin.channels), - ); - const catalogEntries = allCatalogEntries.filter((entry) => !installedChannelIds.has(entry.id)); + const { installedCatalogEntries, installableCatalogEntries } = resolveChannelSetupEntries({ + cfg: params.cfg, + installedPlugins, + workspaceDir, + }); const resolveAdapter = params.resolveAdapter ?? ((channel: ChannelChoice) => @@ -167,8 +165,7 @@ async function collectChannelStatus(params: { quickstartScore: 0, }; }); - const discoveredPluginStatuses = allCatalogEntries - .filter((entry) => installedChannelIds.has(entry.id)) + const discoveredPluginStatuses = installedCatalogEntries .filter((entry) => !statusByChannel.has(entry.id as ChannelChoice)) .map((entry) => { const configured = isChannelConfigured(params.cfg, entry.id); @@ -189,7 +186,7 @@ async function collectChannelStatus(params: { quickstartScore: 0, }; }); - const catalogStatuses = catalogEntries.map((entry) => ({ + const catalogStatuses = installableCatalogEntries.map((entry) => ({ channel: entry.id, configured: false, statusLines: [`${entry.meta.label}: install plugin to enable`], @@ -206,7 +203,8 @@ async function collectChannelStatus(params: { const statusLines = combinedStatuses.flatMap((entry) => entry.statusLines); return { installedPlugins, - catalogEntries, + catalogEntries: installableCatalogEntries, + installedCatalogEntries, statusByChannel: mergedStatusByChannel, statusLines, }; @@ -428,14 +426,19 @@ export async function setupChannels( } preloadConfiguredExternalPlugins(); - const { installedPlugins, catalogEntries, statusByChannel, statusLines } = - await collectChannelStatus({ - cfg: next, - options, - accountOverrides, - installedPlugins: listVisibleInstalledPlugins(), - resolveAdapter: getVisibleOnboardingAdapter, - }); + const { + installedPlugins, + catalogEntries, + installedCatalogEntries, + statusByChannel, + statusLines, + } = await collectChannelStatus({ + cfg: next, + options, + accountOverrides, + installedPlugins: listVisibleInstalledPlugins(), + resolveAdapter: getVisibleOnboardingAdapter, + }); if (!options?.skipStatusNote && statusLines.length > 0) { await prompter.note(statusLines.join("\n"), "Channel status"); } @@ -465,6 +468,13 @@ export async function setupChannels( label: plugin.meta.label, blurb: plugin.meta.blurb, })), + ...installedCatalogEntries + .filter((entry) => !coreIds.has(entry.id as ChannelChoice)) + .map((entry) => ({ + id: entry.id as ChannelChoice, + label: entry.meta.label, + blurb: entry.meta.blurb, + })), ...catalogEntries .filter((entry) => !coreIds.has(entry.id as ChannelChoice)) .map((entry) => ({ @@ -542,33 +552,15 @@ export async function setupChannels( }); const getChannelEntries = () => { - const core = listChatChannels(); - const installed = listVisibleInstalledPlugins(); - const installedIds = new Set(installed.map((plugin) => plugin.id)); - const workspaceDir = resolveWorkspaceDir(); - const catalog = listChannelPluginCatalogEntries({ workspaceDir }).filter( - (entry) => !installedIds.has(entry.id), - ); - const metaById = new Map(); - for (const meta of core) { - metaById.set(meta.id, meta); - } - for (const plugin of installed) { - metaById.set(plugin.id, plugin.meta); - } - for (const entry of catalog) { - if (!metaById.has(entry.id)) { - metaById.set(entry.id, entry.meta); - } - } - const entries = Array.from(metaById, ([id, meta]) => ({ - id: id as ChannelChoice, - meta, - })); + const resolved = resolveChannelSetupEntries({ + cfg: next, + installedPlugins: listVisibleInstalledPlugins(), + workspaceDir: resolveWorkspaceDir(), + }); return { - entries, - catalog, - catalogById: new Map(catalog.map((entry) => [entry.id as ChannelChoice, entry])), + entries: resolved.entries, + catalogById: resolved.installableCatalogById, + installedCatalogById: resolved.installedCatalogById, }; }; @@ -746,8 +738,9 @@ export async function setupChannels( }; const handleChannelChoice = async (channel: ChannelChoice) => { - const { catalogById } = getChannelEntries(); + const { catalogById, installedCatalogById } = getChannelEntries(); const catalogEntry = catalogById.get(channel); + const installedCatalogEntry = installedCatalogById.get(channel); if (catalogEntry) { const workspaceDir = resolveWorkspaceDir(); const result = await ensureOnboardingPluginInstalled({ @@ -763,6 +756,13 @@ export async function setupChannels( } await loadScopedChannelPlugin(channel, result.pluginId ?? catalogEntry.pluginId); await refreshStatus(channel); + } else if (installedCatalogEntry) { + const plugin = await loadScopedChannelPlugin(channel, installedCatalogEntry.pluginId); + if (!plugin) { + await prompter.note(`${channel} plugin not available.`, "Channel setup"); + return; + } + await refreshStatus(channel); } else { const enabled = await enableBundledPluginForSetup(channel); if (!enabled) { diff --git a/src/plugin-sdk/entrypoints.ts b/src/plugin-sdk/entrypoints.ts new file mode 100644 index 00000000000..04b7902de9e --- /dev/null +++ b/src/plugin-sdk/entrypoints.ts @@ -0,0 +1,36 @@ +import pluginSdkEntryList from "../../scripts/lib/plugin-sdk-entrypoints.json" with { type: "json" }; + +export const pluginSdkEntrypoints = [...pluginSdkEntryList]; + +export const pluginSdkSubpaths = pluginSdkEntrypoints.filter((entry) => entry !== "index"); + +export function buildPluginSdkEntrySources() { + return Object.fromEntries( + pluginSdkEntrypoints.map((entry) => [entry, `src/plugin-sdk/${entry}.ts`]), + ); +} + +export function buildPluginSdkSpecifiers() { + return pluginSdkEntrypoints.map((entry) => + entry === "index" ? "openclaw/plugin-sdk" : `openclaw/plugin-sdk/${entry}`, + ); +} + +export function buildPluginSdkPackageExports() { + return Object.fromEntries( + pluginSdkEntrypoints.map((entry) => [ + entry === "index" ? "./plugin-sdk" : `./plugin-sdk/${entry}`, + { + types: `./dist/plugin-sdk/${entry}.d.ts`, + default: `./dist/plugin-sdk/${entry}.js`, + }, + ]), + ); +} + +export function listPluginSdkDistArtifacts() { + return pluginSdkEntrypoints.flatMap((entry) => [ + `dist/plugin-sdk/${entry}.js`, + `dist/plugin-sdk/${entry}.d.ts`, + ]); +} diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts index dd99550b122..d634f80ce66 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.ts @@ -9,7 +9,7 @@ import { buildPluginSdkPackageExports, buildPluginSdkSpecifiers, pluginSdkEntrypoints, -} from "../../scripts/lib/plugin-sdk-entries.mjs"; +} from "./entrypoints.js"; import * as sdk from "./index.js"; const pluginSdkSpecifiers = buildPluginSdkSpecifiers(); diff --git a/src/plugin-sdk/nostr.ts b/src/plugin-sdk/nostr.ts index 381e5e71a8a..a2997c5702c 100644 --- a/src/plugin-sdk/nostr.ts +++ b/src/plugin-sdk/nostr.ts @@ -2,6 +2,7 @@ // Keep this list additive and scoped to symbols used under extensions/nostr. export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; @@ -18,3 +19,4 @@ export { } from "./status-helpers.js"; export { createFixedWindowRateLimiter } from "./webhook-memory-guards.js"; export { mapAllowFromEntries } from "./channel-config-helpers.js"; +export { nostrSetupAdapter, nostrSetupWizard } from "../../extensions/nostr/src/setup-surface.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 6e4b942b9a9..a483e5aaf30 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -4,12 +4,13 @@ import * as discordSdk from "openclaw/plugin-sdk/discord"; import * as imessageSdk from "openclaw/plugin-sdk/imessage"; import * as lineSdk from "openclaw/plugin-sdk/line"; import * as msteamsSdk from "openclaw/plugin-sdk/msteams"; +import * as nostrSdk from "openclaw/plugin-sdk/nostr"; import * as signalSdk from "openclaw/plugin-sdk/signal"; import * as slackSdk from "openclaw/plugin-sdk/slack"; import * as telegramSdk from "openclaw/plugin-sdk/telegram"; import * as whatsappSdk from "openclaw/plugin-sdk/whatsapp"; import { describe, expect, it } from "vitest"; -import { pluginSdkSubpaths } from "../../scripts/lib/plugin-sdk-entries.mjs"; +import { pluginSdkSubpaths } from "./entrypoints.js"; const importPluginSdkSubpath = (specifier: string) => import(/* @vite-ignore */ specifier); @@ -93,6 +94,11 @@ describe("plugin-sdk subpath exports", () => { expect(typeof msteamsSdk.msteamsSetupAdapter).toBe("object"); }); + it("exports Nostr helpers", () => { + expect(typeof nostrSdk.nostrSetupWizard).toBe("object"); + expect(typeof nostrSdk.nostrSetupAdapter).toBe("object"); + }); + it("exports Google Chat helpers", async () => { const googlechatSdk = await import("openclaw/plugin-sdk/googlechat"); expect(typeof googlechatSdk.googlechatSetupWizard).toBe("object"); From bc6ca4940b3f27e6c958fad91cf150016a361296 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 19:57:10 -0700 Subject: [PATCH 151/943] fix: drop duplicate channel setup import --- src/commands/onboard-channels.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 4fa8807d55e..103f81cbff9 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -6,7 +6,6 @@ import { listChannelSetupPlugins, } from "../channels/plugins/setup-registry.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; -import type { ChannelPlugin } from "../channels/plugins/types.js"; import { formatChannelPrimerLine, formatChannelSelectionLine, @@ -22,15 +21,15 @@ import type { RuntimeEnv } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; import { resolveChannelSetupEntries } from "./channel-setup/discovery.js"; +import { + loadBundledChannelOnboardingPlugin, + resolveChannelOnboardingAdapterForPlugin, +} from "./channel-setup/registry.js"; import type { ChannelChoice } from "./onboard-types.js"; import { ensureOnboardingPluginInstalled, loadOnboardingPluginRegistrySnapshotForChannel, } from "./onboarding/plugin-install.js"; -import { - loadBundledChannelOnboardingPlugin, - resolveChannelOnboardingAdapterForPlugin, -} from "./channel-setup/registry.js"; import type { ChannelOnboardingAdapter, ChannelOnboardingConfiguredResult, From d8b927ee6a9f5e1a4d2c262630a4e637d9e27427 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:02:05 -0700 Subject: [PATCH 152/943] feat: add openshell sandbox backend --- CHANGELOG.md | 1 + extensions/openshell/index.ts | 30 ++ extensions/openshell/openclaw.plugin.json | 99 ++++ extensions/openshell/package.json | 12 + extensions/openshell/src/backend.test.ts | 117 +++++ extensions/openshell/src/backend.ts | 445 ++++++++++++++++++ extensions/openshell/src/cli.test.ts | 37 ++ extensions/openshell/src/cli.ts | 166 +++++++ extensions/openshell/src/config.test.ts | 28 ++ extensions/openshell/src/config.ts | 225 +++++++++ extensions/openshell/src/fs-bridge.test.ts | 88 ++++ extensions/openshell/src/fs-bridge.ts | 336 +++++++++++++ extensions/openshell/src/mirror.ts | 47 ++ src/agents/bash-tools.exec-runtime.ts | 28 +- src/agents/bash-tools.shared.ts | 13 + ...ed-runner.buildembeddedsandboxinfo.test.ts | 3 + src/agents/pi-tools-agent-config.test.ts | 3 + src/agents/pi-tools.ts | 4 +- src/agents/sandbox-merge.test.ts | 5 + .../sandbox.resolveSandboxContext.test.ts | 42 ++ src/agents/sandbox.ts | 20 + src/agents/sandbox/backend.test.ts | 39 ++ src/agents/sandbox/backend.ts | 148 ++++++ src/agents/sandbox/browser.create.test.ts | 1 + src/agents/sandbox/config.ts | 1 + src/agents/sandbox/context.ts | 53 ++- src/agents/sandbox/docker-backend.ts | 130 +++++ .../docker.config-hash-recreate.test.ts | 1 + src/agents/sandbox/docker.ts | 3 + src/agents/sandbox/fs-bridge.ts | 36 +- src/agents/sandbox/manage.ts | 114 +++-- src/agents/sandbox/prune.ts | 41 +- src/agents/sandbox/registry.test.ts | 22 + src/agents/sandbox/registry.ts | 26 +- src/agents/sandbox/test-fixtures.ts | 3 + src/agents/sandbox/types.ts | 6 + .../test-helpers/pi-tools-sandbox-context.ts | 3 + src/commands/doctor-sandbox.ts | 15 + src/commands/sandbox-display.ts | 27 +- src/commands/sandbox.test.ts | 16 +- src/commands/sandbox.ts | 4 +- src/config/types.agents-shared.ts | 2 + src/config/zod-schema.agent-runtime.ts | 1 + src/plugin-sdk/core.ts | 23 + 44 files changed, 2343 insertions(+), 121 deletions(-) create mode 100644 extensions/openshell/index.ts create mode 100644 extensions/openshell/openclaw.plugin.json create mode 100644 extensions/openshell/package.json create mode 100644 extensions/openshell/src/backend.test.ts create mode 100644 extensions/openshell/src/backend.ts create mode 100644 extensions/openshell/src/cli.test.ts create mode 100644 extensions/openshell/src/cli.ts create mode 100644 extensions/openshell/src/config.test.ts create mode 100644 extensions/openshell/src/config.ts create mode 100644 extensions/openshell/src/fs-bridge.test.ts create mode 100644 extensions/openshell/src/fs-bridge.ts create mode 100644 extensions/openshell/src/mirror.ts create mode 100644 src/agents/sandbox/backend.test.ts create mode 100644 src/agents/sandbox/backend.ts create mode 100644 src/agents/sandbox/docker-backend.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 232cbb167a1..98208595e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc. - Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus. - secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. +- Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend in mirror mode, and make sandbox list/recreate/prune backend-aware instead of Docker-only. ### Fixes diff --git a/extensions/openshell/index.ts b/extensions/openshell/index.ts new file mode 100644 index 00000000000..910abe31b44 --- /dev/null +++ b/extensions/openshell/index.ts @@ -0,0 +1,30 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { registerSandboxBackend } from "openclaw/plugin-sdk/core"; +import { + createOpenShellSandboxBackendFactory, + createOpenShellSandboxBackendManager, +} from "./src/backend.js"; +import { createOpenShellPluginConfigSchema, resolveOpenShellPluginConfig } from "./src/config.js"; + +const plugin = { + id: "openshell", + name: "OpenShell Sandbox", + description: "OpenShell-backed sandbox runtime for agent exec and file tools.", + configSchema: createOpenShellPluginConfigSchema(), + register(api: OpenClawPluginApi) { + if (api.registrationMode !== "full") { + return; + } + const pluginConfig = resolveOpenShellPluginConfig(api.pluginConfig); + registerSandboxBackend("openshell", { + factory: createOpenShellSandboxBackendFactory({ + pluginConfig, + }), + manager: createOpenShellSandboxBackendManager({ + pluginConfig, + }), + }); + }, +}; + +export default plugin; diff --git a/extensions/openshell/openclaw.plugin.json b/extensions/openshell/openclaw.plugin.json new file mode 100644 index 00000000000..cf3f9ad5579 --- /dev/null +++ b/extensions/openshell/openclaw.plugin.json @@ -0,0 +1,99 @@ +{ + "id": "openshell", + "name": "OpenShell Sandbox", + "description": "Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution.", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "command": { + "type": "string" + }, + "gateway": { + "type": "string" + }, + "gatewayEndpoint": { + "type": "string" + }, + "from": { + "type": "string" + }, + "policy": { + "type": "string" + }, + "providers": { + "type": "array", + "items": { + "type": "string" + } + }, + "gpu": { + "type": "boolean" + }, + "autoProviders": { + "type": "boolean" + }, + "remoteWorkspaceDir": { + "type": "string" + }, + "remoteAgentWorkspaceDir": { + "type": "string" + }, + "timeoutSeconds": { + "type": "number", + "minimum": 1 + } + } + }, + "uiHints": { + "command": { + "label": "OpenShell Command", + "help": "Path or command name for the openshell CLI." + }, + "gateway": { + "label": "Gateway Name", + "help": "Optional OpenShell gateway name passed as --gateway." + }, + "gatewayEndpoint": { + "label": "Gateway Endpoint", + "help": "Optional OpenShell gateway endpoint passed as --gateway-endpoint." + }, + "from": { + "label": "Sandbox Source", + "help": "OpenShell sandbox source for first-time create. Defaults to openclaw." + }, + "policy": { + "label": "Policy File", + "help": "Optional path to a custom OpenShell sandbox policy YAML." + }, + "providers": { + "label": "Providers", + "help": "Provider names to attach when a sandbox is created." + }, + "gpu": { + "label": "GPU", + "help": "Request GPU resources when creating the sandbox.", + "advanced": true + }, + "autoProviders": { + "label": "Auto-create Providers", + "help": "When enabled, pass --auto-providers during sandbox create.", + "advanced": true + }, + "remoteWorkspaceDir": { + "label": "Remote Workspace Dir", + "help": "Primary writable workspace inside the OpenShell sandbox.", + "advanced": true + }, + "remoteAgentWorkspaceDir": { + "label": "Remote Agent Dir", + "help": "Mirror path for the real agent workspace when workspaceAccess is read-only.", + "advanced": true + }, + "timeoutSeconds": { + "label": "Command Timeout Seconds", + "help": "Timeout for openshell CLI operations such as create/upload/download.", + "advanced": true + } + } +} diff --git a/extensions/openshell/package.json b/extensions/openshell/package.json new file mode 100644 index 00000000000..464c749ea34 --- /dev/null +++ b/extensions/openshell/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/openshell-sandbox", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw OpenShell sandbox backend", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/openshell/src/backend.test.ts b/extensions/openshell/src/backend.test.ts new file mode 100644 index 00000000000..2999599c648 --- /dev/null +++ b/extensions/openshell/src/backend.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const cliMocks = vi.hoisted(() => ({ + runOpenShellCli: vi.fn(), +})); + +vi.mock("./cli.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runOpenShellCli: cliMocks.runOpenShellCli, + }; +}); + +import { createOpenShellSandboxBackendManager } from "./backend.js"; +import { resolveOpenShellPluginConfig } from "./config.js"; + +describe("openshell backend manager", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("checks runtime status with config override from OpenClaw config", async () => { + cliMocks.runOpenShellCli.mockResolvedValue({ + code: 0, + stdout: "{}", + stderr: "", + }); + + const manager = createOpenShellSandboxBackendManager({ + pluginConfig: resolveOpenShellPluginConfig({ + command: "openshell", + from: "openclaw", + }), + }); + + const result = await manager.describeRuntime({ + entry: { + containerName: "openclaw-session-1234", + backendId: "openshell", + runtimeLabel: "openclaw-session-1234", + sessionKey: "agent:main", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "custom-source", + configLabelKind: "Source", + }, + config: { + plugins: { + entries: { + openshell: { + enabled: true, + config: { + command: "openshell", + from: "custom-source", + }, + }, + }, + }, + }, + }); + + expect(result).toEqual({ + running: true, + actualConfigLabel: "custom-source", + configLabelMatch: true, + }); + expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({ + context: expect.objectContaining({ + sandboxName: "openclaw-session-1234", + config: expect.objectContaining({ + from: "custom-source", + }), + }), + args: ["sandbox", "get", "openclaw-session-1234"], + }); + }); + + it("removes runtimes via openshell sandbox delete", async () => { + cliMocks.runOpenShellCli.mockResolvedValue({ + code: 0, + stdout: "", + stderr: "", + }); + + const manager = createOpenShellSandboxBackendManager({ + pluginConfig: resolveOpenShellPluginConfig({ + command: "/usr/local/bin/openshell", + gateway: "lab", + }), + }); + + await manager.removeRuntime({ + entry: { + containerName: "openclaw-session-5678", + backendId: "openshell", + runtimeLabel: "openclaw-session-5678", + sessionKey: "agent:main", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "openclaw", + configLabelKind: "Source", + }, + }); + + expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({ + context: expect.objectContaining({ + sandboxName: "openclaw-session-5678", + config: expect.objectContaining({ + command: "/usr/local/bin/openshell", + gateway: "lab", + }), + }), + args: ["sandbox", "delete", "openclaw-session-5678"], + }); + }); +}); diff --git a/extensions/openshell/src/backend.ts b/extensions/openshell/src/backend.ts new file mode 100644 index 00000000000..48f730946d4 --- /dev/null +++ b/extensions/openshell/src/backend.ts @@ -0,0 +1,445 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { + CreateSandboxBackendParams, + OpenClawConfig, + SandboxBackendCommandParams, + SandboxBackendCommandResult, + SandboxBackendFactory, + SandboxBackendHandle, + SandboxBackendManager, +} from "openclaw/plugin-sdk/core"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/core"; +import { + buildExecRemoteCommand, + buildRemoteCommand, + createOpenShellSshSession, + disposeOpenShellSshSession, + runOpenShellCli, + runOpenShellSshCommand, + type OpenShellExecContext, + type OpenShellSshSession, +} from "./cli.js"; +import { resolveOpenShellPluginConfig, type ResolvedOpenShellPluginConfig } from "./config.js"; +import { createOpenShellFsBridge } from "./fs-bridge.js"; +import { replaceDirectoryContents } from "./mirror.js"; + +type CreateOpenShellSandboxBackendFactoryParams = { + pluginConfig: ResolvedOpenShellPluginConfig; +}; + +type PendingExec = { + sshSession: OpenShellSshSession; +}; + +export type OpenShellSandboxBackend = SandboxBackendHandle & { + remoteWorkspaceDir: string; + remoteAgentWorkspaceDir: string; + runRemoteShellScript(params: SandboxBackendCommandParams): Promise; + syncLocalPathToRemote(localPath: string, remotePath: string): Promise; +}; + +export function createOpenShellSandboxBackendFactory( + params: CreateOpenShellSandboxBackendFactoryParams, +): SandboxBackendFactory { + return async (createParams) => + await createOpenShellSandboxBackend({ + ...params, + createParams, + }); +} + +export function createOpenShellSandboxBackendManager(params: { + pluginConfig: ResolvedOpenShellPluginConfig; +}): SandboxBackendManager { + return { + async describeRuntime({ entry, config }) { + const execContext: OpenShellExecContext = { + config: resolveOpenShellPluginConfigFromConfig(config, params.pluginConfig), + sandboxName: entry.containerName, + }; + const result = await runOpenShellCli({ + context: execContext, + args: ["sandbox", "get", entry.containerName], + }); + const configuredSource = execContext.config.from; + return { + running: result.code === 0, + actualConfigLabel: entry.image, + configLabelMatch: entry.image === configuredSource, + }; + }, + async removeRuntime({ entry }) { + const execContext: OpenShellExecContext = { + config: params.pluginConfig, + sandboxName: entry.containerName, + }; + await runOpenShellCli({ + context: execContext, + args: ["sandbox", "delete", entry.containerName], + }); + }, + }; +} + +async function createOpenShellSandboxBackend(params: { + pluginConfig: ResolvedOpenShellPluginConfig; + createParams: CreateSandboxBackendParams; +}): Promise { + if ((params.createParams.cfg.docker.binds?.length ?? 0) > 0) { + throw new Error("OpenShell sandbox backend does not support sandbox.docker.binds."); + } + + const sandboxName = buildOpenShellSandboxName(params.createParams.scopeKey); + const execContext: OpenShellExecContext = { + config: params.pluginConfig, + sandboxName, + }; + const impl = new OpenShellSandboxBackendImpl({ + createParams: params.createParams, + execContext, + remoteWorkspaceDir: params.pluginConfig.remoteWorkspaceDir, + remoteAgentWorkspaceDir: params.pluginConfig.remoteAgentWorkspaceDir, + }); + + return { + id: "openshell", + runtimeId: sandboxName, + runtimeLabel: sandboxName, + workdir: params.pluginConfig.remoteWorkspaceDir, + env: params.createParams.cfg.docker.env, + configLabel: params.pluginConfig.from, + configLabelKind: "Source", + buildExecSpec: async ({ command, workdir, env, usePty }) => { + const pending = await impl.prepareExec({ command, workdir, env, usePty }); + return { + argv: pending.argv, + env: process.env, + stdinMode: "pipe-open", + finalizeToken: pending.token, + }; + }, + finalizeExec: async ({ token }) => { + await impl.finalizeExec(token as PendingExec | undefined); + }, + runShellCommand: async (command) => await impl.runRemoteShellScript(command), + createFsBridge: ({ sandbox }) => + createOpenShellFsBridge({ + sandbox, + backend: impl.asHandle(), + }), + remoteWorkspaceDir: params.pluginConfig.remoteWorkspaceDir, + remoteAgentWorkspaceDir: params.pluginConfig.remoteAgentWorkspaceDir, + runRemoteShellScript: async (command) => await impl.runRemoteShellScript(command), + syncLocalPathToRemote: async (localPath, remotePath) => + await impl.syncLocalPathToRemote(localPath, remotePath), + }; +} + +class OpenShellSandboxBackendImpl { + private ensurePromise: Promise | null = null; + + constructor( + private readonly params: { + createParams: CreateSandboxBackendParams; + execContext: OpenShellExecContext; + remoteWorkspaceDir: string; + remoteAgentWorkspaceDir: string; + }, + ) {} + + asHandle(): OpenShellSandboxBackend { + const self = this; + return { + id: "openshell", + runtimeId: this.params.execContext.sandboxName, + runtimeLabel: this.params.execContext.sandboxName, + workdir: this.params.remoteWorkspaceDir, + env: this.params.createParams.cfg.docker.env, + configLabel: this.params.execContext.config.from, + configLabelKind: "Source", + remoteWorkspaceDir: this.params.remoteWorkspaceDir, + remoteAgentWorkspaceDir: this.params.remoteAgentWorkspaceDir, + buildExecSpec: async ({ command, workdir, env, usePty }) => { + const pending = await self.prepareExec({ command, workdir, env, usePty }); + return { + argv: pending.argv, + env: process.env, + stdinMode: "pipe-open", + finalizeToken: pending.token, + }; + }, + finalizeExec: async ({ token }) => { + await self.finalizeExec(token as PendingExec | undefined); + }, + runShellCommand: async (command) => await self.runRemoteShellScript(command), + createFsBridge: ({ sandbox }) => + createOpenShellFsBridge({ + sandbox, + backend: self.asHandle(), + }), + runRemoteShellScript: async (command) => await self.runRemoteShellScript(command), + syncLocalPathToRemote: async (localPath, remotePath) => + await self.syncLocalPathToRemote(localPath, remotePath), + }; + } + + async prepareExec(params: { + command: string; + workdir?: string; + env: Record; + usePty: boolean; + }): Promise<{ argv: string[]; token: PendingExec }> { + await this.ensureSandboxExists(); + await this.syncWorkspaceToRemote(); + const sshSession = await createOpenShellSshSession({ + context: this.params.execContext, + }); + const remoteCommand = buildExecRemoteCommand({ + command: params.command, + workdir: params.workdir ?? this.params.remoteWorkspaceDir, + env: params.env, + }); + return { + argv: [ + "ssh", + "-F", + sshSession.configPath, + ...(params.usePty + ? ["-tt", "-o", "RequestTTY=force", "-o", "SetEnv=TERM=xterm-256color"] + : ["-T", "-o", "RequestTTY=no"]), + sshSession.host, + remoteCommand, + ], + token: { sshSession }, + }; + } + + async finalizeExec(token?: PendingExec): Promise { + try { + await this.syncWorkspaceFromRemote(); + } finally { + if (token?.sshSession) { + await disposeOpenShellSshSession(token.sshSession); + } + } + } + + async runRemoteShellScript( + params: SandboxBackendCommandParams, + ): Promise { + await this.ensureSandboxExists(); + const session = await createOpenShellSshSession({ + context: this.params.execContext, + }); + try { + return await runOpenShellSshCommand({ + session, + remoteCommand: buildRemoteCommand([ + "/bin/sh", + "-c", + params.script, + "openclaw-openshell-fs", + ...(params.args ?? []), + ]), + stdin: params.stdin, + allowFailure: params.allowFailure, + signal: params.signal, + }); + } finally { + await disposeOpenShellSshSession(session); + } + } + + async syncLocalPathToRemote(localPath: string, remotePath: string): Promise { + await this.ensureSandboxExists(); + const stats = await fs.lstat(localPath).catch(() => null); + if (!stats) { + await this.runRemoteShellScript({ + script: 'rm -rf -- "$1"', + args: [remotePath], + allowFailure: true, + }); + return; + } + if (stats.isDirectory()) { + await this.runRemoteShellScript({ + script: 'mkdir -p -- "$1"', + args: [remotePath], + }); + return; + } + await this.runRemoteShellScript({ + script: 'mkdir -p -- "$(dirname -- "$1")"', + args: [remotePath], + }); + const result = await runOpenShellCli({ + context: this.params.execContext, + args: [ + "sandbox", + "upload", + "--no-git-ignore", + this.params.execContext.sandboxName, + localPath, + path.posix.dirname(remotePath), + ], + cwd: this.params.createParams.workspaceDir, + }); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "openshell sandbox upload failed"); + } + } + + private async ensureSandboxExists(): Promise { + if (this.ensurePromise) { + return await this.ensurePromise; + } + this.ensurePromise = this.ensureSandboxExistsInner(); + try { + await this.ensurePromise; + } catch (error) { + this.ensurePromise = null; + throw error; + } + } + + private async ensureSandboxExistsInner(): Promise { + const getResult = await runOpenShellCli({ + context: this.params.execContext, + args: ["sandbox", "get", this.params.execContext.sandboxName], + cwd: this.params.createParams.workspaceDir, + }); + if (getResult.code === 0) { + return; + } + const createArgs = [ + "sandbox", + "create", + "--name", + this.params.execContext.sandboxName, + "--from", + this.params.execContext.config.from, + ...(this.params.execContext.config.policy + ? ["--policy", this.params.execContext.config.policy] + : []), + ...(this.params.execContext.config.gpu ? ["--gpu"] : []), + ...(this.params.execContext.config.autoProviders + ? ["--auto-providers"] + : ["--no-auto-providers"]), + ...this.params.execContext.config.providers.flatMap((provider) => ["--provider", provider]), + "--", + "true", + ]; + const createResult = await runOpenShellCli({ + context: this.params.execContext, + args: createArgs, + cwd: this.params.createParams.workspaceDir, + timeoutMs: Math.max(this.params.execContext.config.timeoutMs, 300_000), + }); + if (createResult.code !== 0) { + throw new Error(createResult.stderr.trim() || "openshell sandbox create failed"); + } + } + + private async syncWorkspaceToRemote(): Promise { + await this.runRemoteShellScript({ + script: 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +', + args: [this.params.remoteWorkspaceDir], + }); + await this.uploadPathToRemote( + this.params.createParams.workspaceDir, + this.params.remoteWorkspaceDir, + ); + + if ( + this.params.createParams.cfg.workspaceAccess !== "none" && + path.resolve(this.params.createParams.agentWorkspaceDir) !== + path.resolve(this.params.createParams.workspaceDir) + ) { + await this.runRemoteShellScript({ + script: 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +', + args: [this.params.remoteAgentWorkspaceDir], + }); + await this.uploadPathToRemote( + this.params.createParams.agentWorkspaceDir, + this.params.remoteAgentWorkspaceDir, + ); + } + } + + private async syncWorkspaceFromRemote(): Promise { + const tmpDir = await fs.mkdtemp( + path.join(resolveOpenShellTmpRoot(), "openclaw-openshell-sync-"), + ); + try { + const result = await runOpenShellCli({ + context: this.params.execContext, + args: [ + "sandbox", + "download", + this.params.execContext.sandboxName, + this.params.remoteWorkspaceDir, + tmpDir, + ], + cwd: this.params.createParams.workspaceDir, + }); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "openshell sandbox download failed"); + } + await replaceDirectoryContents({ + sourceDir: tmpDir, + targetDir: this.params.createParams.workspaceDir, + }); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + } + + private async uploadPathToRemote(localPath: string, remotePath: string): Promise { + const result = await runOpenShellCli({ + context: this.params.execContext, + args: [ + "sandbox", + "upload", + "--no-git-ignore", + this.params.execContext.sandboxName, + localPath, + remotePath, + ], + cwd: this.params.createParams.workspaceDir, + }); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "openshell sandbox upload failed"); + } + } +} + +function resolveOpenShellPluginConfigFromConfig( + config: OpenClawConfig, + fallback: ResolvedOpenShellPluginConfig, +): ResolvedOpenShellPluginConfig { + const pluginConfig = config.plugins?.entries?.openshell?.config; + if (!pluginConfig) { + return fallback; + } + return resolveOpenShellPluginConfig(pluginConfig); +} + +function buildOpenShellSandboxName(scopeKey: string): string { + const trimmed = scopeKey.trim() || "session"; + const safe = trimmed + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 32); + const hash = Array.from(trimmed).reduce( + (acc, char) => ((acc * 33) ^ char.charCodeAt(0)) >>> 0, + 5381, + ); + return `openclaw-${safe || "session"}-${hash.toString(16).slice(0, 8)}`; +} + +function resolveOpenShellTmpRoot(): string { + return path.resolve(resolvePreferredOpenClawTmpDir() ?? os.tmpdir()); +} diff --git a/extensions/openshell/src/cli.test.ts b/extensions/openshell/src/cli.test.ts new file mode 100644 index 00000000000..d039a571ebc --- /dev/null +++ b/extensions/openshell/src/cli.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { buildExecRemoteCommand, buildOpenShellBaseArgv, shellEscape } from "./cli.js"; +import { resolveOpenShellPluginConfig } from "./config.js"; + +describe("openshell cli helpers", () => { + it("builds base argv with gateway overrides", () => { + const config = resolveOpenShellPluginConfig({ + command: "/usr/local/bin/openshell", + gateway: "lab", + gatewayEndpoint: "https://lab.example", + }); + expect(buildOpenShellBaseArgv(config)).toEqual([ + "/usr/local/bin/openshell", + "--gateway", + "lab", + "--gateway-endpoint", + "https://lab.example", + ]); + }); + + it("shell escapes single quotes", () => { + expect(shellEscape(`a'b`)).toBe(`'a'"'"'b'`); + }); + + it("wraps exec commands with env and workdir", () => { + const command = buildExecRemoteCommand({ + command: "pwd && printenv TOKEN", + workdir: "/sandbox/project", + env: { + TOKEN: "abc 123", + }, + }); + expect(command).toContain(`'env'`); + expect(command).toContain(`'TOKEN=abc 123'`); + expect(command).toContain(`'cd '"'"'/sandbox/project'"'"' && pwd && printenv TOKEN'`); + }); +}); diff --git a/extensions/openshell/src/cli.ts b/extensions/openshell/src/cli.ts new file mode 100644 index 00000000000..8f9808b5164 --- /dev/null +++ b/extensions/openshell/src/cli.ts @@ -0,0 +1,166 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + resolvePreferredOpenClawTmpDir, + runPluginCommandWithTimeout, +} from "openclaw/plugin-sdk/core"; +import type { SandboxBackendCommandResult } from "openclaw/plugin-sdk/core"; +import type { ResolvedOpenShellPluginConfig } from "./config.js"; + +export type OpenShellExecContext = { + config: ResolvedOpenShellPluginConfig; + sandboxName: string; + timeoutMs?: number; +}; + +export type OpenShellSshSession = { + configPath: string; + host: string; +}; + +export type OpenShellRunSshCommandParams = { + session: OpenShellSshSession; + remoteCommand: string; + stdin?: Buffer | string; + allowFailure?: boolean; + signal?: AbortSignal; + tty?: boolean; +}; + +export function buildOpenShellBaseArgv(config: ResolvedOpenShellPluginConfig): string[] { + const argv = [config.command]; + if (config.gateway) { + argv.push("--gateway", config.gateway); + } + if (config.gatewayEndpoint) { + argv.push("--gateway-endpoint", config.gatewayEndpoint); + } + return argv; +} + +export function shellEscape(value: string): string { + return `'${value.replaceAll("'", `'\"'\"'`)}'`; +} + +export function buildRemoteCommand(argv: string[]): string { + return argv.map((entry) => shellEscape(entry)).join(" "); +} + +export async function runOpenShellCli(params: { + context: OpenShellExecContext; + args: string[]; + cwd?: string; + timeoutMs?: number; +}): Promise<{ code: number; stdout: string; stderr: string }> { + return await runPluginCommandWithTimeout({ + argv: [...buildOpenShellBaseArgv(params.context.config), ...params.args], + cwd: params.cwd, + timeoutMs: params.timeoutMs ?? params.context.timeoutMs ?? params.context.config.timeoutMs, + env: process.env, + }); +} + +export async function createOpenShellSshSession(params: { + context: OpenShellExecContext; +}): Promise { + const result = await runOpenShellCli({ + context: params.context, + args: ["sandbox", "ssh-config", params.context.sandboxName], + }); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "openshell sandbox ssh-config failed"); + } + const hostMatch = result.stdout.match(/^\s*Host\s+(\S+)/m); + const host = hostMatch?.[1]?.trim(); + if (!host) { + throw new Error("Failed to parse openshell ssh-config output."); + } + const tmpRoot = resolvePreferredOpenClawTmpDir() || os.tmpdir(); + await fs.mkdir(tmpRoot, { recursive: true }); + const configDir = await fs.mkdtemp(path.join(tmpRoot, "openclaw-openshell-ssh-")); + const configPath = path.join(configDir, "config"); + await fs.writeFile(configPath, result.stdout, "utf8"); + return { configPath, host }; +} + +export async function disposeOpenShellSshSession(session: OpenShellSshSession): Promise { + await fs.rm(path.dirname(session.configPath), { recursive: true, force: true }); +} + +export async function runOpenShellSshCommand( + params: OpenShellRunSshCommandParams, +): Promise { + const argv = [ + "ssh", + "-F", + params.session.configPath, + ...(params.tty + ? ["-tt", "-o", "RequestTTY=force", "-o", "SetEnv=TERM=xterm-256color"] + : ["-T", "-o", "RequestTTY=no"]), + params.session.host, + params.remoteCommand, + ]; + + const result = await new Promise((resolve, reject) => { + const child = spawn(argv[0]!, argv.slice(1), { + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + signal: params.signal, + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); + child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); + child.on("error", reject); + child.on("close", (code) => { + const stdout = Buffer.concat(stdoutChunks); + const stderr = Buffer.concat(stderrChunks); + const exitCode = code ?? 0; + if (exitCode !== 0 && !params.allowFailure) { + const error = Object.assign( + new Error(stderr.toString("utf8").trim() || `ssh exited with code ${exitCode}`), + { + code: exitCode, + stdout, + stderr, + }, + ); + reject(error); + return; + } + resolve({ stdout, stderr, code: exitCode }); + }); + + if (params.stdin !== undefined) { + child.stdin.end(params.stdin); + return; + } + child.stdin.end(); + }); + + return result; +} + +export function buildExecRemoteCommand(params: { + command: string; + workdir?: string; + env: Record; +}): string { + const body = params.workdir + ? `cd ${shellEscape(params.workdir)} && ${params.command}` + : params.command; + const argv = + Object.keys(params.env).length > 0 + ? [ + "env", + ...Object.entries(params.env).map(([key, value]) => `${key}=${value}`), + "/bin/sh", + "-c", + body, + ] + : ["/bin/sh", "-c", body]; + return buildRemoteCommand(argv); +} diff --git a/extensions/openshell/src/config.test.ts b/extensions/openshell/src/config.test.ts new file mode 100644 index 00000000000..66734ca43e0 --- /dev/null +++ b/extensions/openshell/src/config.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { resolveOpenShellPluginConfig } from "./config.js"; + +describe("openshell plugin config", () => { + it("applies defaults", () => { + expect(resolveOpenShellPluginConfig(undefined)).toEqual({ + command: "openshell", + gateway: undefined, + gatewayEndpoint: undefined, + from: "openclaw", + policy: undefined, + providers: [], + gpu: false, + autoProviders: true, + remoteWorkspaceDir: "/sandbox", + remoteAgentWorkspaceDir: "/agent", + timeoutMs: 120_000, + }); + }); + + it("rejects relative remote paths", () => { + expect(() => + resolveOpenShellPluginConfig({ + remoteWorkspaceDir: "sandbox", + }), + ).toThrow("OpenShell remote path must be absolute"); + }); +}); diff --git a/extensions/openshell/src/config.ts b/extensions/openshell/src/config.ts new file mode 100644 index 00000000000..53e5f06584b --- /dev/null +++ b/extensions/openshell/src/config.ts @@ -0,0 +1,225 @@ +import path from "node:path"; +import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/core"; + +export type OpenShellPluginConfig = { + command?: string; + gateway?: string; + gatewayEndpoint?: string; + from?: string; + policy?: string; + providers?: string[]; + gpu?: boolean; + autoProviders?: boolean; + remoteWorkspaceDir?: string; + remoteAgentWorkspaceDir?: string; + timeoutSeconds?: number; +}; + +export type ResolvedOpenShellPluginConfig = { + command: string; + gateway?: string; + gatewayEndpoint?: string; + from: string; + policy?: string; + providers: string[]; + gpu: boolean; + autoProviders: boolean; + remoteWorkspaceDir: string; + remoteAgentWorkspaceDir: string; + timeoutMs: number; +}; + +const DEFAULT_COMMAND = "openshell"; +const DEFAULT_SOURCE = "openclaw"; +const DEFAULT_REMOTE_WORKSPACE_DIR = "/sandbox"; +const DEFAULT_REMOTE_AGENT_WORKSPACE_DIR = "/agent"; +const DEFAULT_TIMEOUT_MS = 120_000; + +type ParseSuccess = { success: true; data?: OpenShellPluginConfig }; +type ParseFailure = { + success: false; + error: { + issues: Array<{ path: Array; message: string }>; + }; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function trimString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +function normalizeProviders(value: unknown): string[] | null { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + return null; + } + const seen = new Set(); + const providers: string[] = []; + for (const entry of value) { + if (typeof entry !== "string" || !entry.trim()) { + return null; + } + const normalized = entry.trim(); + if (seen.has(normalized)) { + continue; + } + seen.add(normalized); + providers.push(normalized); + } + return providers; +} + +function normalizeRemotePath(value: string | undefined, fallback: string): string { + const candidate = value ?? fallback; + const normalized = path.posix.normalize(candidate.trim() || fallback); + if (!normalized.startsWith("/")) { + throw new Error(`OpenShell remote path must be absolute: ${candidate}`); + } + return normalized; +} + +export function createOpenShellPluginConfigSchema(): OpenClawPluginConfigSchema { + const safeParse = (value: unknown): ParseSuccess | ParseFailure => { + if (value === undefined) { + return { success: true, data: undefined }; + } + if (!isRecord(value)) { + return { + success: false, + error: { issues: [{ path: [], message: "expected config object" }] }, + }; + } + const allowedKeys = new Set([ + "command", + "gateway", + "gatewayEndpoint", + "from", + "policy", + "providers", + "gpu", + "autoProviders", + "remoteWorkspaceDir", + "remoteAgentWorkspaceDir", + "timeoutSeconds", + ]); + for (const key of Object.keys(value)) { + if (!allowedKeys.has(key)) { + return { + success: false, + error: { issues: [{ path: [key], message: `unknown config key: ${key}` }] }, + }; + } + } + + const providers = normalizeProviders(value.providers); + if (providers === null) { + return { + success: false, + error: { + issues: [{ path: ["providers"], message: "providers must be an array of strings" }], + }, + }; + } + + const timeoutSeconds = value.timeoutSeconds; + if ( + timeoutSeconds !== undefined && + (typeof timeoutSeconds !== "number" || !Number.isFinite(timeoutSeconds) || timeoutSeconds < 1) + ) { + return { + success: false, + error: { + issues: [{ path: ["timeoutSeconds"], message: "timeoutSeconds must be a number >= 1" }], + }, + }; + } + + for (const key of ["gpu", "autoProviders"] as const) { + const candidate = value[key]; + if (candidate !== undefined && typeof candidate !== "boolean") { + return { + success: false, + error: { issues: [{ path: [key], message: `${key} must be a boolean` }] }, + }; + } + } + + return { + success: true, + data: { + command: trimString(value.command), + gateway: trimString(value.gateway), + gatewayEndpoint: trimString(value.gatewayEndpoint), + from: trimString(value.from), + policy: trimString(value.policy), + providers, + gpu: value.gpu as boolean | undefined, + autoProviders: value.autoProviders as boolean | undefined, + remoteWorkspaceDir: trimString(value.remoteWorkspaceDir), + remoteAgentWorkspaceDir: trimString(value.remoteAgentWorkspaceDir), + timeoutSeconds: timeoutSeconds as number | undefined, + }, + }; + }; + + return { + safeParse, + jsonSchema: { + type: "object", + additionalProperties: false, + properties: { + command: { type: "string" }, + gateway: { type: "string" }, + gatewayEndpoint: { type: "string" }, + from: { type: "string" }, + policy: { type: "string" }, + providers: { type: "array", items: { type: "string" } }, + gpu: { type: "boolean" }, + autoProviders: { type: "boolean" }, + remoteWorkspaceDir: { type: "string" }, + remoteAgentWorkspaceDir: { type: "string" }, + timeoutSeconds: { type: "number", minimum: 1 }, + }, + }, + }; +} + +export function resolveOpenShellPluginConfig(value: unknown): ResolvedOpenShellPluginConfig { + const parsed = createOpenShellPluginConfigSchema().safeParse?.(value); + if (!parsed || !parsed.success) { + const issues = parsed && !parsed.success ? parsed.error?.issues : undefined; + const message = + issues?.map((issue: { message: string }) => issue.message).join(", ") || "invalid config"; + throw new Error(`Invalid openshell plugin config: ${message}`); + } + const raw = parsed.data ?? {}; + const cfg = (raw ?? {}) as OpenShellPluginConfig; + return { + command: cfg.command ?? DEFAULT_COMMAND, + gateway: cfg.gateway, + gatewayEndpoint: cfg.gatewayEndpoint, + from: cfg.from ?? DEFAULT_SOURCE, + policy: cfg.policy, + providers: cfg.providers ?? [], + gpu: cfg.gpu ?? false, + autoProviders: cfg.autoProviders ?? true, + remoteWorkspaceDir: normalizeRemotePath(cfg.remoteWorkspaceDir, DEFAULT_REMOTE_WORKSPACE_DIR), + remoteAgentWorkspaceDir: normalizeRemotePath( + cfg.remoteAgentWorkspaceDir, + DEFAULT_REMOTE_AGENT_WORKSPACE_DIR, + ), + timeoutMs: + typeof cfg.timeoutSeconds === "number" + ? Math.floor(cfg.timeoutSeconds * 1000) + : DEFAULT_TIMEOUT_MS, + }; +} diff --git a/extensions/openshell/src/fs-bridge.test.ts b/extensions/openshell/src/fs-bridge.test.ts new file mode 100644 index 00000000000..67a3edc5bcc --- /dev/null +++ b/extensions/openshell/src/fs-bridge.test.ts @@ -0,0 +1,88 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createSandboxTestContext } from "../../../src/agents/sandbox/test-fixtures.js"; +import type { OpenShellSandboxBackend } from "./backend.js"; +import { createOpenShellFsBridge } from "./fs-bridge.js"; + +const tempDirs: string[] = []; + +async function makeTempDir() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-openshell-fs-")); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +function createBackendMock(): OpenShellSandboxBackend { + return { + id: "openshell", + runtimeId: "openshell-test", + runtimeLabel: "openshell-test", + workdir: "/sandbox", + env: {}, + remoteWorkspaceDir: "/sandbox", + remoteAgentWorkspaceDir: "/agent", + buildExecSpec: vi.fn(), + runShellCommand: vi.fn(), + runRemoteShellScript: vi.fn().mockResolvedValue({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }), + syncLocalPathToRemote: vi.fn().mockResolvedValue(undefined), + } as unknown as OpenShellSandboxBackend; +} + +describe("openshell fs bridge", () => { + it("writes locally and syncs the file to the remote workspace", async () => { + const workspaceDir = await makeTempDir(); + const backend = createBackendMock(); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir: workspaceDir, + containerWorkdir: "/sandbox", + }, + }); + + const bridge = createOpenShellFsBridge({ sandbox, backend }); + await bridge.writeFile({ + filePath: "nested/file.txt", + data: "hello", + mkdir: true, + }); + + expect(await fs.readFile(path.join(workspaceDir, "nested", "file.txt"), "utf8")).toBe("hello"); + expect(backend.syncLocalPathToRemote).toHaveBeenCalledWith( + path.join(workspaceDir, "nested", "file.txt"), + "/sandbox/nested/file.txt", + ); + }); + + it("maps agent mount paths when the sandbox workspace is read-only", async () => { + const workspaceDir = await makeTempDir(); + const agentWorkspaceDir = await makeTempDir(); + await fs.writeFile(path.join(agentWorkspaceDir, "note.txt"), "agent", "utf8"); + const backend = createBackendMock(); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir, + workspaceAccess: "ro", + containerWorkdir: "/sandbox", + }, + }); + + const bridge = createOpenShellFsBridge({ sandbox, backend }); + const resolved = bridge.resolvePath({ filePath: "/agent/note.txt" }); + expect(resolved.hostPath).toBe(path.join(agentWorkspaceDir, "note.txt")); + expect(await bridge.readFile({ filePath: "/agent/note.txt" })).toEqual(Buffer.from("agent")); + }); +}); diff --git a/extensions/openshell/src/fs-bridge.ts b/extensions/openshell/src/fs-bridge.ts new file mode 100644 index 00000000000..b9ab9b01549 --- /dev/null +++ b/extensions/openshell/src/fs-bridge.ts @@ -0,0 +1,336 @@ +import fsPromises from "node:fs/promises"; +import path from "node:path"; +import type { + SandboxContext, + SandboxFsBridge, + SandboxFsStat, + SandboxResolvedPath, +} from "openclaw/plugin-sdk/core"; +import type { OpenShellSandboxBackend } from "./backend.js"; +import { movePathWithCopyFallback } from "./mirror.js"; + +type ResolvedMountPath = SandboxResolvedPath & { + mountHostRoot: string; + writable: boolean; + source: "workspace" | "agent"; +}; + +export function createOpenShellFsBridge(params: { + sandbox: SandboxContext; + backend: OpenShellSandboxBackend; +}): SandboxFsBridge { + return new OpenShellFsBridge(params.sandbox, params.backend); +} + +class OpenShellFsBridge implements SandboxFsBridge { + constructor( + private readonly sandbox: SandboxContext, + private readonly backend: OpenShellSandboxBackend, + ) {} + + resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath { + const target = this.resolveTarget(params); + return { + hostPath: target.hostPath, + relativePath: target.relativePath, + containerPath: target.containerPath, + }; + } + + async readFile(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + await assertLocalPathSafety({ + target, + root: target.mountHostRoot, + allowMissingLeaf: false, + allowFinalSymlinkForUnlink: false, + }); + return await fsPromises.readFile(target.hostPath); + } + + async writeFile(params: { + filePath: string; + cwd?: string; + data: Buffer | string; + encoding?: BufferEncoding; + mkdir?: boolean; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "write files"); + await assertLocalPathSafety({ + target, + root: target.mountHostRoot, + allowMissingLeaf: true, + allowFinalSymlinkForUnlink: false, + }); + const buffer = Buffer.isBuffer(params.data) + ? params.data + : Buffer.from(params.data, params.encoding ?? "utf8"); + const parentDir = path.dirname(target.hostPath); + if (params.mkdir !== false) { + await fsPromises.mkdir(parentDir, { recursive: true }); + } + const tempPath = path.join( + parentDir, + `.openclaw-openshell-write-${path.basename(target.hostPath)}-${process.pid}-${Date.now()}`, + ); + await fsPromises.writeFile(tempPath, buffer); + await fsPromises.rename(tempPath, target.hostPath); + await this.backend.syncLocalPathToRemote(target.hostPath, target.containerPath); + } + + async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "create directories"); + await assertLocalPathSafety({ + target, + root: target.mountHostRoot, + allowMissingLeaf: true, + allowFinalSymlinkForUnlink: false, + }); + await fsPromises.mkdir(target.hostPath, { recursive: true }); + await this.backend.runRemoteShellScript({ + script: 'mkdir -p -- "$1"', + args: [target.containerPath], + signal: params.signal, + }); + } + + async remove(params: { + filePath: string; + cwd?: string; + recursive?: boolean; + force?: boolean; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "remove files"); + await assertLocalPathSafety({ + target, + root: target.mountHostRoot, + allowMissingLeaf: params.force !== false, + allowFinalSymlinkForUnlink: true, + }); + await fsPromises.rm(target.hostPath, { + recursive: params.recursive ?? false, + force: params.force !== false, + }); + await this.backend.runRemoteShellScript({ + script: params.recursive + ? 'rm -rf -- "$1"' + : 'if [ -d "$1" ] && [ ! -L "$1" ]; then rmdir -- "$1"; elif [ -e "$1" ] || [ -L "$1" ]; then rm -f -- "$1"; fi', + args: [target.containerPath], + signal: params.signal, + allowFailure: params.force !== false, + }); + } + + async rename(params: { + from: string; + to: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const from = this.resolveTarget({ filePath: params.from, cwd: params.cwd }); + const to = this.resolveTarget({ filePath: params.to, cwd: params.cwd }); + this.ensureWritable(from, "rename files"); + this.ensureWritable(to, "rename files"); + await assertLocalPathSafety({ + target: from, + root: from.mountHostRoot, + allowMissingLeaf: false, + allowFinalSymlinkForUnlink: true, + }); + await assertLocalPathSafety({ + target: to, + root: to.mountHostRoot, + allowMissingLeaf: true, + allowFinalSymlinkForUnlink: false, + }); + await fsPromises.mkdir(path.dirname(to.hostPath), { recursive: true }); + await movePathWithCopyFallback({ from: from.hostPath, to: to.hostPath }); + await this.backend.runRemoteShellScript({ + script: 'mkdir -p -- "$(dirname -- "$2")" && mv -- "$1" "$2"', + args: [from.containerPath, to.containerPath], + signal: params.signal, + }); + } + + async stat(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + const stats = await fsPromises.lstat(target.hostPath).catch(() => null); + if (!stats) { + return null; + } + await assertLocalPathSafety({ + target, + root: target.mountHostRoot, + allowMissingLeaf: false, + allowFinalSymlinkForUnlink: false, + }); + return { + type: stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other", + size: stats.size, + mtimeMs: stats.mtimeMs, + }; + } + + private ensureWritable(target: ResolvedMountPath, action: string) { + if (this.sandbox.workspaceAccess !== "rw" || !target.writable) { + throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`); + } + } + + private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedMountPath { + const workspaceRoot = path.resolve(this.sandbox.workspaceDir); + const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir); + const hasAgentMount = this.sandbox.workspaceAccess !== "none" && workspaceRoot !== agentRoot; + const agentContainerRoot = (this.backend.remoteAgentWorkspaceDir || "/agent").replace( + /\\/g, + "/", + ); + const workspaceContainerRoot = this.sandbox.containerWorkdir.replace(/\\/g, "/"); + const input = params.filePath.trim(); + + if (input.startsWith(`${workspaceContainerRoot}/`) || input === workspaceContainerRoot) { + const relative = path.posix.relative(workspaceContainerRoot, input) || ""; + const hostPath = relative + ? path.resolve(workspaceRoot, ...relative.split("/")) + : workspaceRoot; + return { + hostPath, + relativePath: relative, + containerPath: relative + ? path.posix.join(workspaceContainerRoot, relative) + : workspaceContainerRoot, + mountHostRoot: workspaceRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "workspace", + }; + } + + if ( + hasAgentMount && + (input.startsWith(`${agentContainerRoot}/`) || input === agentContainerRoot) + ) { + const relative = path.posix.relative(agentContainerRoot, input) || ""; + const hostPath = relative ? path.resolve(agentRoot, ...relative.split("/")) : agentRoot; + return { + hostPath, + relativePath: relative ? agentContainerRoot + "/" + relative : agentContainerRoot, + containerPath: relative + ? path.posix.join(agentContainerRoot, relative) + : agentContainerRoot, + mountHostRoot: agentRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "agent", + }; + } + + const cwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot; + const hostPath = path.isAbsolute(input) ? path.resolve(input) : path.resolve(cwd, input); + + if (isPathInside(workspaceRoot, hostPath)) { + const relative = path.relative(workspaceRoot, hostPath).split(path.sep).join(path.posix.sep); + return { + hostPath, + relativePath: relative, + containerPath: relative + ? path.posix.join(workspaceContainerRoot, relative) + : workspaceContainerRoot, + mountHostRoot: workspaceRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "workspace", + }; + } + + if (hasAgentMount && isPathInside(agentRoot, hostPath)) { + const relative = path.relative(agentRoot, hostPath).split(path.sep).join(path.posix.sep); + return { + hostPath, + relativePath: relative ? `${agentContainerRoot}/${relative}` : agentContainerRoot, + containerPath: relative + ? path.posix.join(agentContainerRoot, relative) + : agentContainerRoot, + mountHostRoot: agentRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "agent", + }; + } + + throw new Error(`Path escapes sandbox root (${workspaceRoot}): ${params.filePath}`); + } +} + +function isPathInside(root: string, target: string): boolean { + const relative = path.relative(root, target); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +async function assertLocalPathSafety(params: { + target: ResolvedMountPath; + root: string; + allowMissingLeaf: boolean; + allowFinalSymlinkForUnlink: boolean; +}): Promise { + const canonicalRoot = await fsPromises + .realpath(params.root) + .catch(() => path.resolve(params.root)); + const candidate = await resolveCanonicalCandidate(params.target.hostPath); + if (!isPathInside(canonicalRoot, candidate)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot access: ${params.target.containerPath}`, + ); + } + + const relative = path.relative(params.root, params.target.hostPath); + const segments = relative + .split(path.sep) + .filter(Boolean) + .slice(0, Math.max(0, relative.split(path.sep).filter(Boolean).length)); + let cursor = params.root; + for (let index = 0; index < segments.length; index += 1) { + cursor = path.join(cursor, segments[index]!); + const stats = await fsPromises.lstat(cursor).catch(() => null); + if (!stats) { + if (index === segments.length - 1 && params.allowMissingLeaf) { + return; + } + continue; + } + const isFinal = index === segments.length - 1; + if (stats.isSymbolicLink() && (!isFinal || !params.allowFinalSymlinkForUnlink)) { + throw new Error(`Sandbox boundary checks failed: ${params.target.containerPath}`); + } + } +} + +async function resolveCanonicalCandidate(targetPath: string): Promise { + const missing: string[] = []; + let cursor = path.resolve(targetPath); + while (true) { + const exists = await fsPromises + .lstat(cursor) + .then(() => true) + .catch(() => false); + if (exists) { + const canonical = await fsPromises.realpath(cursor).catch(() => cursor); + return path.resolve(canonical, ...missing); + } + const parent = path.dirname(cursor); + if (parent === cursor) { + return path.resolve(cursor, ...missing); + } + missing.unshift(path.basename(cursor)); + cursor = parent; + } +} diff --git a/extensions/openshell/src/mirror.ts b/extensions/openshell/src/mirror.ts new file mode 100644 index 00000000000..ee5024850d6 --- /dev/null +++ b/extensions/openshell/src/mirror.ts @@ -0,0 +1,47 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export async function replaceDirectoryContents(params: { + sourceDir: string; + targetDir: string; +}): Promise { + await fs.mkdir(params.targetDir, { recursive: true }); + const existing = await fs.readdir(params.targetDir); + await Promise.all( + existing.map((entry) => + fs.rm(path.join(params.targetDir, entry), { + recursive: true, + force: true, + }), + ), + ); + const sourceEntries = await fs.readdir(params.sourceDir); + for (const entry of sourceEntries) { + await fs.cp(path.join(params.sourceDir, entry), path.join(params.targetDir, entry), { + recursive: true, + force: true, + dereference: false, + }); + } +} + +export async function movePathWithCopyFallback(params: { + from: string; + to: string; +}): Promise { + try { + await fs.rename(params.from, params.to); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code; + if (code !== "EXDEV") { + throw error; + } + } + await fs.cp(params.from, params.to, { + recursive: true, + force: true, + dereference: false, + }); + await fs.rm(params.from, { recursive: true, force: true }); +} diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 5c3301414b9..72367deb33d 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -384,6 +384,7 @@ export async function runExecProcess(opts: { typeof opts.timeoutSec === "number" && opts.timeoutSec > 0 ? Math.floor(opts.timeoutSec * 1000) : undefined; + let sandboxFinalizeToken: unknown; const spawnSpec: | { @@ -398,11 +399,18 @@ export async function runExecProcess(opts: { childFallbackArgv: string[]; env: NodeJS.ProcessEnv; stdinMode: "pipe-open"; - } = (() => { + } = await (async () => { if (opts.sandbox) { + const backendExecSpec = await opts.sandbox.buildExecSpec?.({ + command: execCommand, + workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir, + env: shellRuntimeEnv, + usePty: opts.usePty, + }); + sandboxFinalizeToken = backendExecSpec?.finalizeToken; return { mode: "child" as const, - argv: [ + argv: backendExecSpec?.argv ?? [ "docker", ...buildDockerExecArgs({ containerName: opts.sandbox.containerName, @@ -412,8 +420,10 @@ export async function runExecProcess(opts: { tty: opts.usePty, }), ], - env: process.env, - stdinMode: opts.usePty ? ("pipe-open" as const) : ("pipe-closed" as const), + env: backendExecSpec?.env ?? process.env, + stdinMode: + backendExecSpec?.stdinMode ?? + (opts.usePty ? ("pipe-open" as const) : ("pipe-closed" as const)), }; } const { shell, args: shellArgs } = getShellConfig(); @@ -519,7 +529,7 @@ export async function runExecProcess(opts: { const promise = managedRun .wait() - .then((exit): ExecProcessOutcome => { + .then(async (exit): Promise => { const durationMs = Date.now() - startedAt; const isNormalExit = exit.reason === "exit"; const exitCode = exit.exitCode ?? 0; @@ -536,6 +546,14 @@ export async function runExecProcess(opts: { session.stdin.destroyed = true; } const aggregated = session.aggregated.trim(); + if (opts.sandbox?.finalizeExec) { + await opts.sandbox.finalizeExec({ + status, + exitCode: exit.exitCode ?? null, + timedOut: exit.timedOut, + token: sandboxFinalizeToken, + }); + } if (status === "completed") { const exitMsg = exitCode !== 0 ? `\n\n(Command exited with code ${exitCode})` : ""; return { diff --git a/src/agents/bash-tools.shared.ts b/src/agents/bash-tools.shared.ts index 3cfb92655e2..25f1fb5bd8d 100644 --- a/src/agents/bash-tools.shared.ts +++ b/src/agents/bash-tools.shared.ts @@ -4,6 +4,7 @@ import { homedir } from "node:os"; import path from "node:path"; import { sliceUtf16Safe } from "../utils.js"; import { assertSandboxPath } from "./sandbox-paths.js"; +import type { SandboxBackendExecSpec } from "./sandbox/backend.js"; const CHUNK_LIMIT = 8 * 1024; @@ -12,6 +13,18 @@ export type BashSandboxConfig = { workspaceDir: string; containerWorkdir: string; env?: Record; + buildExecSpec?: (params: { + command: string; + workdir?: string; + env: Record; + usePty: boolean; + }) => Promise; + finalizeExec?: (params: { + status: "completed" | "failed"; + exitCode: number | null; + timedOut: boolean; + token?: unknown; + }) => Promise; }; export function buildSandboxEnv(params: { diff --git a/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts b/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts index 8b225ff89cb..52289130690 100644 --- a/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts +++ b/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts @@ -5,10 +5,13 @@ import type { SandboxContext } from "./sandbox.js"; function createSandboxContext(overrides?: Partial): SandboxContext { const base = { enabled: true, + backendId: "docker", sessionKey: "session:test", workspaceDir: "/tmp/openclaw-sandbox", agentWorkspaceDir: "/tmp/openclaw-workspace", workspaceAccess: "none", + runtimeId: "openclaw-sbx-test", + runtimeLabel: "openclaw-sbx-test", containerName: "openclaw-sbx-test", containerWorkdir: "/workspace", docker: { diff --git a/src/agents/pi-tools-agent-config.test.ts b/src/agents/pi-tools-agent-config.test.ts index e24186e0b30..353b0333759 100644 --- a/src/agents/pi-tools-agent-config.test.ts +++ b/src/agents/pi-tools-agent-config.test.ts @@ -574,10 +574,13 @@ describe("Agent-specific tool filtering", () => { agentDir: "/tmp/agent-restricted", sandbox: { enabled: true, + backendId: "docker", sessionKey: "agent:restricted:main", workspaceDir: "/tmp/sandbox", agentWorkspaceDir: "/tmp/test-restricted", workspaceAccess: "none", + runtimeId: "test-container", + runtimeLabel: "test-container", containerName: "test-container", containerWorkdir: "/workspace", docker: { diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 6536e9dfbb5..9c7aafbd56e 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -438,7 +438,9 @@ export function createOpenClawCodingTools(options?: { containerName: sandbox.containerName, workspaceDir: sandbox.workspaceDir, containerWorkdir: sandbox.containerWorkdir, - env: sandbox.docker.env, + env: sandbox.backend?.env ?? sandbox.docker.env, + buildExecSpec: sandbox.backend?.buildExecSpec.bind(sandbox.backend), + finalizeExec: sandbox.backend?.finalizeExec?.bind(sandbox.backend), } : undefined, }); diff --git a/src/agents/sandbox-merge.test.ts b/src/agents/sandbox-merge.test.ts index 0635703b8bb..d120ac84820 100644 --- a/src/agents/sandbox-merge.test.ts +++ b/src/agents/sandbox-merge.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { resolveSandboxBrowserConfig, + resolveSandboxConfigForAgent, resolveSandboxDockerConfig, resolveSandboxPruneConfig, resolveSandboxScope, @@ -128,4 +129,8 @@ describe("sandbox config merges", () => { }); expect(pruneShared).toEqual({ idleHours: 24, maxAgeDays: 7 }); }); + + it("defaults sandbox backend to docker", () => { + expect(resolveSandboxConfigForAgent().backend).toBe("docker"); + }); }); diff --git a/src/agents/sandbox.resolveSandboxContext.test.ts b/src/agents/sandbox.resolveSandboxContext.test.ts index 2ecec621a70..0fa62a364e2 100644 --- a/src/agents/sandbox.resolveSandboxContext.test.ts +++ b/src/agents/sandbox.resolveSandboxContext.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { registerSandboxBackend } from "./sandbox/backend.js"; import { ensureSandboxWorkspaceForSession, resolveSandboxContext } from "./sandbox/context.js"; describe("resolveSandboxContext", () => { @@ -84,4 +85,45 @@ describe("resolveSandboxContext", () => { }), ).toBeNull(); }, 15_000); + + it("resolves a registered non-docker backend", async () => { + const restore = registerSandboxBackend("test-backend", async () => ({ + id: "test-backend", + runtimeId: "test-runtime", + runtimeLabel: "Test Runtime", + workdir: "/workspace", + buildExecSpec: async () => ({ + argv: ["test-backend", "exec"], + env: process.env, + stdinMode: "pipe-closed", + }), + runShellCommand: async () => ({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }), + })); + try { + const cfg: OpenClawConfig = { + agents: { + defaults: { + sandbox: { mode: "all", backend: "test-backend", scope: "session" }, + }, + }, + }; + + const result = await resolveSandboxContext({ + config: cfg, + sessionKey: "agent:worker:task", + workspaceDir: "/tmp/openclaw-test", + }); + + expect(result?.backendId).toBe("test-backend"); + expect(result?.runtimeId).toBe("test-runtime"); + expect(result?.containerName).toBe("test-runtime"); + expect(result?.backend?.id).toBe("test-backend"); + } finally { + restore(); + } + }, 15_000); }); diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 8ac65795d0f..b52cb5ab050 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -11,6 +11,12 @@ export { DEFAULT_SANDBOX_IMAGE, } from "./sandbox/constants.js"; export { ensureSandboxWorkspaceForSession, resolveSandboxContext } from "./sandbox/context.js"; +export { + getSandboxBackendFactory, + getSandboxBackendManager, + registerSandboxBackend, + requireSandboxBackendFactory, +} from "./sandbox/backend.js"; export { buildSandboxCreateArgs } from "./sandbox/docker.js"; export { @@ -27,6 +33,20 @@ export { } from "./sandbox/runtime-status.js"; export { resolveSandboxToolPolicyForAgent } from "./sandbox/tool-policy.js"; +export type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./sandbox/fs-bridge.js"; + +export type { + CreateSandboxBackendParams, + SandboxBackendCommandParams, + SandboxBackendCommandResult, + SandboxBackendExecSpec, + SandboxBackendFactory, + SandboxBackendHandle, + SandboxBackendId, + SandboxBackendManager, + SandboxBackendRegistration, + SandboxBackendRuntimeInfo, +} from "./sandbox/backend.js"; export type { SandboxBrowserConfig, diff --git a/src/agents/sandbox/backend.test.ts b/src/agents/sandbox/backend.test.ts new file mode 100644 index 00000000000..6878e768945 --- /dev/null +++ b/src/agents/sandbox/backend.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + getSandboxBackendFactory, + getSandboxBackendManager, + registerSandboxBackend, +} from "./backend.js"; + +describe("sandbox backend registry", () => { + it("registers and restores backend factories", () => { + const factory = async () => { + throw new Error("not used"); + }; + const restore = registerSandboxBackend("test-backend", factory); + expect(getSandboxBackendFactory("test-backend")).toBe(factory); + restore(); + expect(getSandboxBackendFactory("test-backend")).toBeNull(); + }); + + it("registers backend managers alongside factories", () => { + const factory = async () => { + throw new Error("not used"); + }; + const manager = { + describeRuntime: async () => ({ + running: true, + configLabelMatch: true, + }), + removeRuntime: async () => {}, + }; + const restore = registerSandboxBackend("test-managed", { + factory, + manager, + }); + expect(getSandboxBackendFactory("test-managed")).toBe(factory); + expect(getSandboxBackendManager("test-managed")).toBe(manager); + restore(); + expect(getSandboxBackendManager("test-managed")).toBeNull(); + }); +}); diff --git a/src/agents/sandbox/backend.ts b/src/agents/sandbox/backend.ts new file mode 100644 index 00000000000..c186b0fe4cc --- /dev/null +++ b/src/agents/sandbox/backend.ts @@ -0,0 +1,148 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { SandboxFsBridge } from "./fs-bridge.js"; +import type { SandboxRegistryEntry } from "./registry.js"; +import type { SandboxConfig, SandboxContext } from "./types.js"; + +export type SandboxBackendId = string; + +export type SandboxBackendExecSpec = { + argv: string[]; + env: NodeJS.ProcessEnv; + stdinMode: "pipe-open" | "pipe-closed"; + finalizeToken?: unknown; +}; + +export type SandboxBackendCommandParams = { + script: string; + args?: string[]; + stdin?: Buffer | string; + allowFailure?: boolean; + signal?: AbortSignal; +}; + +export type SandboxBackendCommandResult = { + stdout: Buffer; + stderr: Buffer; + code: number; +}; + +export type SandboxBackendHandle = { + id: SandboxBackendId; + runtimeId: string; + runtimeLabel: string; + workdir: string; + env?: Record; + configLabel?: string; + configLabelKind?: string; + capabilities?: { + browser?: boolean; + }; + buildExecSpec(params: { + command: string; + workdir?: string; + env: Record; + usePty: boolean; + }): Promise; + finalizeExec?: (params: { + status: "completed" | "failed"; + exitCode: number | null; + timedOut: boolean; + token?: unknown; + }) => Promise; + runShellCommand(params: SandboxBackendCommandParams): Promise; + createFsBridge?: (params: { sandbox: SandboxContext }) => SandboxFsBridge; +}; + +export type SandboxBackendRuntimeInfo = { + running: boolean; + actualConfigLabel?: string; + configLabelMatch: boolean; +}; + +export type SandboxBackendManager = { + describeRuntime(params: { + entry: SandboxRegistryEntry; + config: OpenClawConfig; + agentId?: string; + }): Promise; + removeRuntime(params: { entry: SandboxRegistryEntry }): Promise; +}; + +export type CreateSandboxBackendParams = { + sessionKey: string; + scopeKey: string; + workspaceDir: string; + agentWorkspaceDir: string; + cfg: SandboxConfig; +}; + +export type SandboxBackendFactory = ( + params: CreateSandboxBackendParams, +) => Promise; + +export type SandboxBackendRegistration = + | SandboxBackendFactory + | { + factory: SandboxBackendFactory; + manager?: SandboxBackendManager; + }; + +type RegisteredSandboxBackend = { + factory: SandboxBackendFactory; + manager?: SandboxBackendManager; +}; + +const SANDBOX_BACKEND_FACTORIES = new Map(); + +function normalizeSandboxBackendId(id: string): SandboxBackendId { + const normalized = id.trim().toLowerCase(); + if (!normalized) { + throw new Error("Sandbox backend id must not be empty."); + } + return normalized; +} + +export function registerSandboxBackend( + id: string, + registration: SandboxBackendRegistration, +): () => void { + const normalizedId = normalizeSandboxBackendId(id); + const resolved = typeof registration === "function" ? { factory: registration } : registration; + const previous = SANDBOX_BACKEND_FACTORIES.get(normalizedId); + SANDBOX_BACKEND_FACTORIES.set(normalizedId, resolved); + return () => { + if (previous) { + SANDBOX_BACKEND_FACTORIES.set(normalizedId, previous); + return; + } + SANDBOX_BACKEND_FACTORIES.delete(normalizedId); + }; +} + +export function getSandboxBackendFactory(id: string): SandboxBackendFactory | null { + return SANDBOX_BACKEND_FACTORIES.get(normalizeSandboxBackendId(id))?.factory ?? null; +} + +export function getSandboxBackendManager(id: string): SandboxBackendManager | null { + return SANDBOX_BACKEND_FACTORIES.get(normalizeSandboxBackendId(id))?.manager ?? null; +} + +export function requireSandboxBackendFactory(id: string): SandboxBackendFactory { + const factory = getSandboxBackendFactory(id); + if (factory) { + return factory; + } + throw new Error( + [ + `Sandbox backend "${id}" is not registered.`, + "Load the plugin that provides it, or set agents.defaults.sandbox.backend=docker.", + ].join("\n"), + ); +} + +import { createDockerSandboxBackend, dockerSandboxBackendManager } from "./docker-backend.js"; + +registerSandboxBackend("docker", { + factory: createDockerSandboxBackend, + manager: dockerSandboxBackendManager, +}); diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index 077db23c53b..c62276c6b87 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -48,6 +48,7 @@ vi.mock("../../browser/bridge-server.js", () => ({ function buildConfig(enableNoVnc: boolean): SandboxConfig { return { mode: "all", + backend: "docker", scope: "session", workspaceAccess: "none", workspaceRoot: "/tmp/openclaw-sandboxes", diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts index b7595ae8c4b..dda3e048ea7 100644 --- a/src/agents/sandbox/config.ts +++ b/src/agents/sandbox/config.ts @@ -189,6 +189,7 @@ export function resolveSandboxConfigForAgent( return { mode: agentSandbox?.mode ?? agent?.mode ?? "off", + backend: agentSandbox?.backend?.trim() || agent?.backend?.trim() || "docker", scope, workspaceAccess: agentSandbox?.workspaceAccess ?? agent?.workspaceAccess ?? "none", workspaceRoot: diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index 8468dd2c556..031b7c45998 100644 --- a/src/agents/sandbox/context.ts +++ b/src/agents/sandbox/context.ts @@ -7,11 +7,12 @@ import { defaultRuntime } from "../../runtime.js"; import { resolveUserPath } from "../../utils.js"; import { syncSkillsToWorkspace } from "../skills.js"; import { DEFAULT_AGENT_WORKSPACE_DIR } from "../workspace.js"; +import { requireSandboxBackendFactory } from "./backend.js"; import { ensureSandboxBrowser } from "./browser.js"; import { resolveSandboxConfigForAgent } from "./config.js"; -import { ensureSandboxContainer } from "./docker.js"; import { createSandboxFsBridge } from "./fs-bridge.js"; import { maybePruneSandboxes } from "./prune.js"; +import { updateRegistry } from "./registry.js"; import { resolveSandboxRuntimeStatus } from "./runtime-status.js"; import { resolveSandboxScopeKey, resolveSandboxWorkspaceDir } from "./shared.js"; import type { SandboxContext, SandboxDockerConfig, SandboxWorkspaceInfo } from "./types.js"; @@ -131,12 +132,24 @@ export async function resolveSandboxContext(params: { }); const resolvedCfg = docker === cfg.docker ? cfg : { ...cfg, docker }; - const containerName = await ensureSandboxContainer({ + const backendFactory = requireSandboxBackendFactory(resolvedCfg.backend); + const backend = await backendFactory({ sessionKey: rawSessionKey, + scopeKey, workspaceDir, agentWorkspaceDir, cfg: resolvedCfg, }); + await updateRegistry({ + containerName: backend.runtimeId, + backendId: backend.id, + runtimeLabel: backend.runtimeLabel, + sessionKey: scopeKey, + createdAtMs: Date.now(), + lastUsedAtMs: Date.now(), + image: backend.configLabel ?? resolvedCfg.docker.image, + configLabelKind: backend.configLabelKind ?? "Image", + }); const evaluateEnabled = params.config?.browser?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED; @@ -157,30 +170,44 @@ export async function resolveSandboxContext(params: { return browserAuth; })() : undefined; - const browser = await ensureSandboxBrowser({ - scopeKey, - workspaceDir, - agentWorkspaceDir, - cfg: resolvedCfg, - evaluateEnabled, - bridgeAuth, - }); + if (resolvedCfg.browser.enabled && backend.capabilities?.browser !== true) { + throw new Error( + `Sandbox backend "${resolvedCfg.backend}" does not support browser sandboxes yet.`, + ); + } + const browser = + resolvedCfg.browser.enabled && backend.capabilities?.browser === true + ? await ensureSandboxBrowser({ + scopeKey, + workspaceDir, + agentWorkspaceDir, + cfg: resolvedCfg, + evaluateEnabled, + bridgeAuth, + }) + : null; const sandboxContext: SandboxContext = { enabled: true, + backendId: backend.id, sessionKey: rawSessionKey, workspaceDir, agentWorkspaceDir, workspaceAccess: resolvedCfg.workspaceAccess, - containerName, - containerWorkdir: resolvedCfg.docker.workdir, + runtimeId: backend.runtimeId, + runtimeLabel: backend.runtimeLabel, + containerName: backend.runtimeId, + containerWorkdir: backend.workdir, docker: resolvedCfg.docker, tools: resolvedCfg.tools, browserAllowHostControl: resolvedCfg.browser.allowHostControl, browser: browser ?? undefined, + backend, }; - sandboxContext.fsBridge = createSandboxFsBridge({ sandbox: sandboxContext }); + sandboxContext.fsBridge = + backend.createFsBridge?.({ sandbox: sandboxContext }) ?? + createSandboxFsBridge({ sandbox: sandboxContext }); return sandboxContext; } diff --git a/src/agents/sandbox/docker-backend.ts b/src/agents/sandbox/docker-backend.ts new file mode 100644 index 00000000000..9686dc4b612 --- /dev/null +++ b/src/agents/sandbox/docker-backend.ts @@ -0,0 +1,130 @@ +import { buildDockerExecArgs } from "../bash-tools.shared.js"; +import type { + CreateSandboxBackendParams, + SandboxBackendManager, + SandboxBackendCommandParams, + SandboxBackendHandle, +} from "./backend.js"; +import { resolveSandboxConfigForAgent } from "./config.js"; +import { + dockerContainerState, + ensureSandboxContainer, + execDocker, + execDockerRaw, +} from "./docker.js"; + +export async function createDockerSandboxBackend( + params: CreateSandboxBackendParams, +): Promise { + const containerName = await ensureSandboxContainer({ + sessionKey: params.sessionKey, + workspaceDir: params.workspaceDir, + agentWorkspaceDir: params.agentWorkspaceDir, + cfg: params.cfg, + }); + return createDockerSandboxBackendHandle({ + containerName, + workdir: params.cfg.docker.workdir, + env: params.cfg.docker.env, + image: params.cfg.docker.image, + }); +} + +export function createDockerSandboxBackendHandle(params: { + containerName: string; + workdir: string; + env?: Record; + image: string; +}): SandboxBackendHandle { + return { + id: "docker", + runtimeId: params.containerName, + runtimeLabel: params.containerName, + workdir: params.workdir, + env: params.env, + configLabel: params.image, + configLabelKind: "Image", + capabilities: { + browser: true, + }, + async buildExecSpec({ command, workdir, env, usePty }) { + return { + argv: [ + "docker", + ...buildDockerExecArgs({ + containerName: params.containerName, + command, + workdir: workdir ?? params.workdir, + env, + tty: usePty, + }), + ], + env: process.env, + stdinMode: usePty ? "pipe-open" : "pipe-closed", + }; + }, + runShellCommand(command) { + return runDockerSandboxShellCommand({ + containerName: params.containerName, + ...command, + }); + }, + }; +} + +export function runDockerSandboxShellCommand( + params: { + containerName: string; + } & SandboxBackendCommandParams, +) { + const dockerArgs = [ + "exec", + "-i", + params.containerName, + "sh", + "-c", + params.script, + "moltbot-sandbox-fs", + ]; + if (params.args?.length) { + dockerArgs.push(...params.args); + } + return execDockerRaw(dockerArgs, { + input: params.stdin, + allowFailure: params.allowFailure, + signal: params.signal, + }); +} + +export const dockerSandboxBackendManager: SandboxBackendManager = { + async describeRuntime({ entry, config, agentId }) { + const state = await dockerContainerState(entry.containerName); + let actualConfigLabel = entry.image; + if (state.exists) { + try { + const result = await execDocker( + ["inspect", "-f", "{{.Config.Image}}", entry.containerName], + { allowFailure: true }, + ); + if (result.code === 0) { + actualConfigLabel = result.stdout.trim() || actualConfigLabel; + } + } catch { + // ignore inspect failures + } + } + const configuredImage = resolveSandboxConfigForAgent(config, agentId).docker.image; + return { + running: state.running, + actualConfigLabel, + configLabelMatch: actualConfigLabel === configuredImage, + }; + }, + async removeRuntime({ entry }) { + try { + await execDocker(["rm", "-f", entry.containerName], { allowFailure: true }); + } catch { + // ignore removal failures + } + }, +}; diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index b2cd24c6630..54941ba04d1 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -91,6 +91,7 @@ function createSandboxConfig( ): SandboxConfig { return { mode: "all", + backend: "docker", scope: "shared", workspaceAccess, workspaceRoot: "~/.openclaw/sandboxes", diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index aefceb08495..80a2921cb6b 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -557,10 +557,13 @@ export async function ensureSandboxContainer(params: { } await updateRegistry({ containerName, + backendId: "docker", + runtimeLabel: containerName, sessionKey: scopeKey, createdAtMs: now, lastUsedAtMs: now, image: params.cfg.docker.image, + configLabelKind: "Image", configHash: hashMismatch && running ? (currentHash ?? undefined) : expectedHash, }); return containerName; diff --git a/src/agents/sandbox/fs-bridge.ts b/src/agents/sandbox/fs-bridge.ts index 7a9a22d4459..16c307e053c 100644 --- a/src/agents/sandbox/fs-bridge.ts +++ b/src/agents/sandbox/fs-bridge.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; -import { execDockerRaw, type ExecDockerRawResult } from "./docker.js"; +import type { SandboxBackendCommandResult } from "./backend.js"; +import { runDockerSandboxShellCommand } from "./docker-backend.js"; import { buildPinnedMkdirpPlan, buildPinnedRemovePlan, @@ -248,21 +249,22 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { private async runCommand( script: string, options: RunCommandOptions = {}, - ): Promise { - const dockerArgs = [ - "exec", - "-i", - this.sandbox.containerName, - "sh", - "-c", - script, - "moltbot-sandbox-fs", - ]; - if (options.args?.length) { - dockerArgs.push(...options.args); + ): Promise { + const backend = this.sandbox.backend; + if (backend) { + return await backend.runShellCommand({ + script, + args: options.args, + stdin: options.stdin, + allowFailure: options.allowFailure, + signal: options.signal, + }); } - return execDockerRaw(dockerArgs, { - input: options.stdin, + return await runDockerSandboxShellCommand({ + containerName: this.sandbox.containerName, + script, + args: options.args, + stdin: options.stdin, allowFailure: options.allowFailure, signal: options.signal, }); @@ -279,7 +281,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { private async runCheckedCommand( plan: SandboxFsCommandPlan & { stdin?: Buffer | string; signal?: AbortSignal }, - ): Promise { + ): Promise { await this.pathGuard.assertPathChecks(plan.checks); if (plan.recheckBeforeCommand) { await this.pathGuard.assertPathChecks(plan.checks); @@ -295,7 +297,7 @@ class SandboxFsBridgeImpl implements SandboxFsBridge { private async runPlannedCommand( plan: SandboxFsCommandPlan, signal?: AbortSignal, - ): Promise { + ): Promise { return await this.runCheckedCommand({ ...plan, signal }); } diff --git a/src/agents/sandbox/manage.ts b/src/agents/sandbox/manage.ts index f6988146e90..0b5ba578d7d 100644 --- a/src/agents/sandbox/manage.ts +++ b/src/agents/sandbox/manage.ts @@ -1,8 +1,8 @@ import { stopBrowserBridgeServer } from "../../browser/bridge-server.js"; import { loadConfig } from "../../config/config.js"; +import { getSandboxBackendManager } from "./backend.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; -import { resolveSandboxConfigForAgent } from "./config.js"; -import { dockerContainerState, execDocker } from "./docker.js"; +import { dockerSandboxBackendManager } from "./docker-backend.js"; import { readBrowserRegistry, readRegistry, @@ -23,80 +23,92 @@ export type SandboxBrowserInfo = SandboxBrowserRegistryEntry & { imageMatch: boolean; }; -async function listSandboxRegistryItems< - TEntry extends { containerName: string; image: string; sessionKey: string }, ->(params: { - read: () => Promise<{ entries: TEntry[] }>; - resolveConfiguredImage: (agentId?: string) => string; -}): Promise> { - const registry = await params.read(); - const results: Array = []; +export async function listSandboxContainers(): Promise { + const config = loadConfig(); + const registry = await readRegistry(); + const results: SandboxContainerInfo[] = []; for (const entry of registry.entries) { - const state = await dockerContainerState(entry.containerName); - // Get actual image from container. - let actualImage = entry.image; - if (state.exists) { - try { - const result = await execDocker( - ["inspect", "-f", "{{.Config.Image}}", entry.containerName], - { allowFailure: true }, - ); - if (result.code === 0) { - actualImage = result.stdout.trim(); - } - } catch { - // ignore - } + const backendId = entry.backendId ?? "docker"; + const manager = getSandboxBackendManager(backendId); + if (!manager) { + results.push({ + ...entry, + running: false, + imageMatch: true, + }); + continue; } const agentId = resolveSandboxAgentId(entry.sessionKey); - const configuredImage = params.resolveConfiguredImage(agentId); + const runtime = await manager.describeRuntime({ + entry, + config, + agentId, + }); results.push({ ...entry, - image: actualImage, - running: state.running, - imageMatch: actualImage === configuredImage, + image: runtime.actualConfigLabel ?? entry.image, + running: runtime.running, + imageMatch: runtime.configLabelMatch, }); } return results; } -export async function listSandboxContainers(): Promise { - const config = loadConfig(); - return listSandboxRegistryItems({ - read: readRegistry, - resolveConfiguredImage: (agentId) => resolveSandboxConfigForAgent(config, agentId).docker.image, - }); -} - export async function listSandboxBrowsers(): Promise { const config = loadConfig(); - return listSandboxRegistryItems({ - read: readBrowserRegistry, - resolveConfiguredImage: (agentId) => - resolveSandboxConfigForAgent(config, agentId).browser.image, - }); + const registry = await readBrowserRegistry(); + const results: SandboxBrowserInfo[] = []; + + for (const entry of registry.entries) { + const agentId = resolveSandboxAgentId(entry.sessionKey); + const runtime = await dockerSandboxBackendManager.describeRuntime({ + entry: { + ...entry, + backendId: "docker", + runtimeLabel: entry.containerName, + configLabelKind: "Image", + }, + config, + agentId, + }); + results.push({ + ...entry, + image: runtime.actualConfigLabel ?? entry.image, + running: runtime.running, + imageMatch: runtime.configLabelMatch, + }); + } + + return results; } export async function removeSandboxContainer(containerName: string): Promise { - try { - await execDocker(["rm", "-f", containerName], { allowFailure: true }); - } catch { - // ignore removal failures + const registry = await readRegistry(); + const entry = registry.entries.find((item) => item.containerName === containerName); + if (entry) { + const manager = getSandboxBackendManager(entry.backendId ?? "docker"); + await manager?.removeRuntime({ entry }); } await removeRegistryEntry(containerName); } export async function removeSandboxBrowserContainer(containerName: string): Promise { - try { - await execDocker(["rm", "-f", containerName], { allowFailure: true }); - } catch { - // ignore removal failures + const registry = await readBrowserRegistry(); + const entry = registry.entries.find((item) => item.containerName === containerName); + if (entry) { + await dockerSandboxBackendManager.removeRuntime({ + entry: { + ...entry, + backendId: "docker", + runtimeLabel: entry.containerName, + configLabelKind: "Image", + }, + }); } await removeBrowserRegistryEntry(containerName); - // Stop browser bridge if active for (const [sessionKey, bridge] of BROWSER_BRIDGES.entries()) { if (bridge.containerName === containerName) { await stopBrowserBridgeServer(bridge.bridge.server).catch(() => undefined); diff --git a/src/agents/sandbox/prune.ts b/src/agents/sandbox/prune.ts index 45e7fda6308..6ccfd8ac238 100644 --- a/src/agents/sandbox/prune.ts +++ b/src/agents/sandbox/prune.ts @@ -1,7 +1,8 @@ import { stopBrowserBridgeServer } from "../../browser/bridge-server.js"; import { defaultRuntime } from "../../runtime.js"; +import { getSandboxBackendManager } from "./backend.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; -import { dockerContainerState, execDocker } from "./docker.js"; +import { dockerSandboxBackendManager } from "./docker-backend.js"; import { readBrowserRegistry, readRegistry, @@ -16,7 +17,7 @@ let lastPruneAtMs = 0; type PruneableRegistryEntry = Pick< SandboxRegistryEntry, - "containerName" | "createdAtMs" | "lastUsedAtMs" + "containerName" | "backendId" | "createdAtMs" | "lastUsedAtMs" >; function shouldPruneSandboxEntry(cfg: SandboxConfig, now: number, entry: PruneableRegistryEntry) { @@ -33,10 +34,11 @@ function shouldPruneSandboxEntry(cfg: SandboxConfig, now: number, entry: Pruneab ); } -async function pruneSandboxRegistryEntries(params: { +async function pruneSandboxRegistryEntries(params: { cfg: SandboxConfig; read: () => Promise<{ entries: TEntry[] }>; remove: (containerName: string) => Promise; + removeRuntime: (entry: TEntry) => Promise; onRemoved?: (entry: TEntry) => Promise; }) { const now = Date.now(); @@ -49,9 +51,7 @@ async function pruneSandboxRegistryEntries { + const manager = getSandboxBackendManager(entry.backendId ?? "docker"); + await manager?.removeRuntime({ entry }); + }, }); } async function pruneSandboxBrowsers(cfg: SandboxConfig) { - await pruneSandboxRegistryEntries({ + await pruneSandboxRegistryEntries< + SandboxBrowserRegistryEntry & { + backendId?: string; + runtimeLabel?: string; + configLabelKind?: string; + } + >({ cfg, read: readBrowserRegistry, remove: removeBrowserRegistryEntry, + removeRuntime: async (entry) => { + await dockerSandboxBackendManager.removeRuntime({ + entry: { + ...entry, + backendId: "docker", + runtimeLabel: entry.containerName, + configLabelKind: "Image", + }, + }); + }, onRemoved: async (entry) => { const bridge = BROWSER_BRIDGES.get(entry.sessionKey); if (bridge?.containerName === entry.containerName) { @@ -103,10 +123,3 @@ export async function maybePruneSandboxes(cfg: SandboxConfig) { defaultRuntime.error?.(`Sandbox prune failed: ${message ?? "unknown error"}`); } } - -export async function ensureDockerContainerIsRunning(containerName: string) { - const state = await dockerContainerState(containerName); - if (state.exists && !state.running) { - await execDocker(["start", containerName]); - } -} diff --git a/src/agents/sandbox/registry.test.ts b/src/agents/sandbox/registry.test.ts index 2de75190bf8..059e6f77c88 100644 --- a/src/agents/sandbox/registry.test.ts +++ b/src/agents/sandbox/registry.test.ts @@ -172,6 +172,28 @@ async function seedBrowserRegistry(entries: SandboxBrowserRegistryEntry[]) { } describe("registry race safety", () => { + it("normalizes legacy registry entries on read", async () => { + await seedContainerRegistry([ + { + containerName: "legacy-container", + sessionKey: "agent:main", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "openclaw-sandbox:test", + }, + ]); + + const registry = await readRegistry(); + expect(registry.entries).toEqual([ + expect.objectContaining({ + containerName: "legacy-container", + backendId: "docker", + runtimeLabel: "legacy-container", + configLabelKind: "Image", + }), + ]); + }); + it("keeps both container updates under concurrent writes", async () => { await Promise.all([ updateRegistry(containerEntry({ containerName: "container-a" })), diff --git a/src/agents/sandbox/registry.ts b/src/agents/sandbox/registry.ts index 54bb361934b..f8efebbf32b 100644 --- a/src/agents/sandbox/registry.ts +++ b/src/agents/sandbox/registry.ts @@ -5,10 +5,13 @@ import { SANDBOX_BROWSER_REGISTRY_PATH, SANDBOX_REGISTRY_PATH } from "./constant export type SandboxRegistryEntry = { containerName: string; + backendId?: string; + runtimeLabel?: string; sessionKey: string; createdAtMs: number; lastUsedAtMs: number; image: string; + configLabelKind?: string; configHash?: string; }; @@ -42,8 +45,11 @@ type RegistryFile = { }; type UpsertEntry = RegistryEntry & { + backendId?: string; + runtimeLabel?: string; createdAtMs: number; image: string; + configLabelKind?: string; configHash?: string; }; @@ -55,6 +61,15 @@ function isRegistryEntry(value: unknown): value is RegistryEntry { return isRecord(value) && typeof value.containerName === "string"; } +function normalizeSandboxRegistryEntry(entry: SandboxRegistryEntry): SandboxRegistryEntry { + return { + ...entry, + backendId: entry.backendId?.trim() || "docker", + runtimeLabel: entry.runtimeLabel?.trim() || entry.containerName, + configLabelKind: entry.configLabelKind?.trim() || "Image", + }; +} + function isRegistryFile(value: unknown): value is RegistryFile { if (!isRecord(value)) { return false; @@ -110,7 +125,13 @@ async function writeRegistryFile( } export async function readRegistry(): Promise { - return await readRegistryFromFile(SANDBOX_REGISTRY_PATH, "fallback"); + const registry = await readRegistryFromFile( + SANDBOX_REGISTRY_PATH, + "fallback", + ); + return { + entries: registry.entries.map((entry) => normalizeSandboxRegistryEntry(entry)), + }; } function upsertEntry(entries: T[], entry: T): T[] { @@ -118,8 +139,11 @@ function upsertEntry(entries: T[], entry: T): T[] { const next = entries.filter((item) => item.containerName !== entry.containerName); next.push({ ...entry, + backendId: entry.backendId ?? existing?.backendId, + runtimeLabel: entry.runtimeLabel ?? existing?.runtimeLabel, createdAtMs: existing?.createdAtMs ?? entry.createdAtMs, image: existing?.image ?? entry.image, + configLabelKind: entry.configLabelKind ?? existing?.configLabelKind, configHash: entry.configHash ?? existing?.configHash, }); return next; diff --git a/src/agents/sandbox/test-fixtures.ts b/src/agents/sandbox/test-fixtures.ts index db3835dcba5..b20b5b452f7 100644 --- a/src/agents/sandbox/test-fixtures.ts +++ b/src/agents/sandbox/test-fixtures.ts @@ -28,10 +28,13 @@ export function createSandboxTestContext(params?: { return { enabled: true, + backendId: "docker", sessionKey: "sandbox:test", workspaceDir: "/tmp/workspace", agentWorkspaceDir: "/tmp/workspace", workspaceAccess: "rw", + runtimeId: "openclaw-sbx-test", + runtimeLabel: "openclaw-sbx-test", containerName: "openclaw-sbx-test", containerWorkdir: "/workspace", tools: { allow: ["*"], deny: [] }, diff --git a/src/agents/sandbox/types.ts b/src/agents/sandbox/types.ts index 4ccfd691cfb..8244583ea0c 100644 --- a/src/agents/sandbox/types.ts +++ b/src/agents/sandbox/types.ts @@ -1,3 +1,4 @@ +import type { SandboxBackendHandle, SandboxBackendId } from "./backend.js"; import type { SandboxFsBridge } from "./fs-bridge.js"; import type { SandboxDockerConfig } from "./types.docker.js"; @@ -54,6 +55,7 @@ export type SandboxScope = "session" | "agent" | "shared"; export type SandboxConfig = { mode: "off" | "non-main" | "all"; + backend: SandboxBackendId; scope: SandboxScope; workspaceAccess: SandboxWorkspaceAccess; workspaceRoot: string; @@ -71,10 +73,13 @@ export type SandboxBrowserContext = { export type SandboxContext = { enabled: boolean; + backendId: SandboxBackendId; sessionKey: string; workspaceDir: string; agentWorkspaceDir: string; workspaceAccess: SandboxWorkspaceAccess; + runtimeId: string; + runtimeLabel: string; containerName: string; containerWorkdir: string; docker: SandboxDockerConfig; @@ -82,6 +87,7 @@ export type SandboxContext = { browserAllowHostControl: boolean; browser?: SandboxBrowserContext; fsBridge?: SandboxFsBridge; + backend?: SandboxBackendHandle; }; export type SandboxWorkspaceInfo = { diff --git a/src/agents/test-helpers/pi-tools-sandbox-context.ts b/src/agents/test-helpers/pi-tools-sandbox-context.ts index 286c5eed685..abf712c2c0b 100644 --- a/src/agents/test-helpers/pi-tools-sandbox-context.ts +++ b/src/agents/test-helpers/pi-tools-sandbox-context.ts @@ -18,10 +18,13 @@ export function createPiToolsSandboxContext(params: PiToolsSandboxContextParams) const workspaceDir = params.workspaceDir; return { enabled: true, + backendId: "docker", sessionKey: params.sessionKey ?? "sandbox:test", workspaceDir, agentWorkspaceDir: params.agentWorkspaceDir ?? workspaceDir, workspaceAccess: params.workspaceAccess ?? "rw", + runtimeId: params.containerName ?? "openclaw-sbx-test", + runtimeLabel: params.containerName ?? "openclaw-sbx-test", containerName: params.containerName ?? "openclaw-sbx-test", containerWorkdir: params.containerWorkdir ?? "/workspace", fsBridge: params.fsBridge, diff --git a/src/commands/doctor-sandbox.ts b/src/commands/doctor-sandbox.ts index 90790e90737..2138c422fe2 100644 --- a/src/commands/doctor-sandbox.ts +++ b/src/commands/doctor-sandbox.ts @@ -94,6 +94,11 @@ function resolveSandboxDockerImage(cfg: OpenClawConfig): string { return image ? image : DEFAULT_SANDBOX_IMAGE; } +function resolveSandboxBackend(cfg: OpenClawConfig): string { + const backend = cfg.agents?.defaults?.sandbox?.backend?.trim(); + return backend || "docker"; +} + function resolveSandboxBrowserImage(cfg: OpenClawConfig): string { const image = cfg.agents?.defaults?.sandbox?.browser?.image?.trim(); return image ? image : DEFAULT_SANDBOX_BROWSER_IMAGE; @@ -185,6 +190,16 @@ export async function maybeRepairSandboxImages( if (!sandbox || mode === "off") { return cfg; } + const backend = resolveSandboxBackend(cfg); + if (backend !== "docker") { + if (sandbox.browser?.enabled) { + note( + `Sandbox backend "${backend}" selected. Docker browser health checks are skipped; browser sandbox currently requires the docker backend.`, + "Sandbox", + ); + } + return cfg; + } const dockerAvailable = await isDockerAvailable(); if (!dockerAvailable) { diff --git a/src/commands/sandbox-display.ts b/src/commands/sandbox-display.ts index 181af6bcc1f..8eaf245c5bf 100644 --- a/src/commands/sandbox-display.ts +++ b/src/commands/sandbox-display.ts @@ -30,12 +30,15 @@ export function displayContainers(containers: SandboxContainerInfo[], runtime: R displayItems( containers, { - emptyMessage: "No sandbox containers found.", - title: "📦 Sandbox Containers:", + emptyMessage: "No sandbox runtimes found.", + title: "📦 Sandbox Runtimes:", renderItem: (container, rt) => { - rt.log(` ${container.containerName}`); + rt.log(` ${container.runtimeLabel ?? container.containerName}`); rt.log(` Status: ${formatStatus(container.running)}`); - rt.log(` Image: ${container.image} ${formatImageMatch(container.imageMatch)}`); + rt.log( + ` ${container.configLabelKind ?? "Image"}: ${container.image} ${formatImageMatch(container.imageMatch)}`, + ); + rt.log(` Backend: ${container.backendId ?? "docker"}`); rt.log( ` Age: ${formatDurationCompact(Date.now() - container.createdAtMs, { spaced: true }) ?? "0s"}`, ); @@ -92,9 +95,9 @@ export function displaySummary( runtime.log(`Total: ${totalCount} (${runningCount} running)`); if (mismatchCount > 0) { - runtime.log(`\n⚠️ ${mismatchCount} container(s) with image mismatch detected.`); + runtime.log(`\n⚠️ ${mismatchCount} runtime(s) with config mismatch detected.`); runtime.log( - ` Run '${formatCliCommand("openclaw sandbox recreate --all")}' to update all containers.`, + ` Run '${formatCliCommand("openclaw sandbox recreate --all")}' to update all runtimes.`, ); } } @@ -104,12 +107,14 @@ export function displayRecreatePreview( browsers: SandboxBrowserInfo[], runtime: RuntimeEnv, ): void { - runtime.log("\nContainers to be recreated:\n"); + runtime.log("\nSandbox runtimes to be recreated:\n"); if (containers.length > 0) { - runtime.log("📦 Sandbox Containers:"); + runtime.log("📦 Sandbox Runtimes:"); for (const container of containers) { - runtime.log(` - ${container.containerName} (${formatSimpleStatus(container.running)})`); + runtime.log( + ` - ${container.runtimeLabel ?? container.containerName} [${container.backendId ?? "docker"}] (${formatSimpleStatus(container.running)})`, + ); } } @@ -121,7 +126,7 @@ export function displayRecreatePreview( } const total = containers.length + browsers.length; - runtime.log(`\nTotal: ${total} container(s)`); + runtime.log(`\nTotal: ${total} runtime(s)`); } export function displayRecreateResult( @@ -131,6 +136,6 @@ export function displayRecreateResult( runtime.log(`\nDone: ${result.successCount} removed, ${result.failCount} failed`); if (result.successCount > 0) { - runtime.log("\nContainers will be automatically recreated when the agent is next used."); + runtime.log("\nRuntimes will be automatically recreated when the agent is next used."); } } diff --git a/src/commands/sandbox.test.ts b/src/commands/sandbox.test.ts index 384dc2eef41..7425e712c6f 100644 --- a/src/commands/sandbox.test.ts +++ b/src/commands/sandbox.test.ts @@ -29,10 +29,14 @@ import { sandboxListCommand, sandboxRecreateCommand } from "./sandbox.js"; const NOW = Date.now(); function createContainer(overrides: Partial = {}): SandboxContainerInfo { + const containerName = overrides.containerName ?? "openclaw-sandbox-test"; return { - containerName: "openclaw-sandbox-test", + containerName, + backendId: "docker", + runtimeLabel: containerName, sessionKey: "test-session", image: "openclaw/sandbox:latest", + configLabelKind: "Image", imageMatch: true, running: true, createdAtMs: NOW - 3600000, @@ -104,7 +108,7 @@ describe("sandboxListCommand", () => { await sandboxListCommand({ browser: false, json: false }, runtime as never); - expectLogContains(runtime, "📦 Sandbox Containers"); + expectLogContains(runtime, "📦 Sandbox Runtimes"); expectLogContains(runtime, container1.containerName); expectLogContains(runtime, container2.containerName); expectLogContains(runtime, "Total"); @@ -128,14 +132,14 @@ describe("sandboxListCommand", () => { await sandboxListCommand({ browser: false, json: false }, runtime as never); expectLogContains(runtime, "⚠️"); - expectLogContains(runtime, "image mismatch"); + expectLogContains(runtime, "config mismatch"); expectLogContains(runtime, "sandbox recreate --all"); }); it("should display message when no containers found", async () => { await sandboxListCommand({ browser: false, json: false }, runtime as never); - expect(runtime.log).toHaveBeenCalledWith("No sandbox containers found."); + expect(runtime.log).toHaveBeenCalledWith("No sandbox runtimes found."); }); }); @@ -161,7 +165,7 @@ describe("sandboxListCommand", () => { await sandboxListCommand({ browser: false, json: false }, runtime as never); - expect(runtime.log).toHaveBeenCalledWith("No sandbox containers found."); + expect(runtime.log).toHaveBeenCalledWith("No sandbox runtimes found."); }); }); }); @@ -295,7 +299,7 @@ describe("sandboxRecreateCommand", () => { it("should show message when no containers match", async () => { await sandboxRecreateCommand({ all: true, browser: false, force: true }, runtime as never); - expect(runtime.log).toHaveBeenCalledWith("No containers found matching the criteria."); + expect(runtime.log).toHaveBeenCalledWith("No sandbox runtimes found matching the criteria."); expect(mocks.removeSandboxContainer).not.toHaveBeenCalled(); }); diff --git a/src/commands/sandbox.ts b/src/commands/sandbox.ts index e9071ce7810..d6b494fc5aa 100644 --- a/src/commands/sandbox.ts +++ b/src/commands/sandbox.ts @@ -74,7 +74,7 @@ export async function sandboxRecreateCommand( const filtered = await fetchAndFilterContainers(opts); if (filtered.containers.length + filtered.browsers.length === 0) { - runtime.log("No containers found matching the criteria."); + runtime.log("No sandbox runtimes found matching the criteria."); return; } @@ -154,7 +154,7 @@ async function removeContainers( filtered: FilteredContainers, runtime: RuntimeEnv, ): Promise<{ successCount: number; failCount: number }> { - runtime.log("\nRemoving containers...\n"); + runtime.log("\nRemoving sandbox runtimes...\n"); let successCount = 0; let failCount = 0; diff --git a/src/config/types.agents-shared.ts b/src/config/types.agents-shared.ts index 152c8973c11..1e398cc1c70 100644 --- a/src/config/types.agents-shared.ts +++ b/src/config/types.agents-shared.ts @@ -15,6 +15,8 @@ export type AgentModelConfig = export type AgentSandboxConfig = { mode?: "off" | "non-main" | "all"; + /** Sandbox runtime backend id. Default: "docker". */ + backend?: string; /** Agent workspace access inside the sandbox. */ workspaceAccess?: "none" | "ro" | "rw"; /** diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index d7b1dd393e7..2ee70e58ef6 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -496,6 +496,7 @@ const ToolLoopDetectionSchema = z export const AgentSandboxSchema = z .object({ mode: z.union([z.literal("off"), z.literal("non-main"), z.literal("all")]).optional(), + backend: z.string().min(1).optional(), workspaceAccess: z.union([z.literal("none"), z.literal("ro"), z.literal("rw")]).optional(), sessionToolsVisibility: z.union([z.literal("spawned"), z.literal("all")]).optional(), scope: z.union([z.literal("session"), z.literal("agent"), z.literal("shared")]).optional(), diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 4f403343b34..a792af23816 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -1,6 +1,7 @@ export type { AnyAgentTool, OpenClawPluginApi, + OpenClawPluginConfigSchema, ProviderDiscoveryContext, ProviderCatalogContext, ProviderCatalogResult, @@ -25,6 +26,22 @@ export type { ProviderAuthMethodNonInteractiveContext, ProviderAuthResult, } from "../plugins/types.js"; +export type { + CreateSandboxBackendParams, + SandboxBackendCommandParams, + SandboxBackendCommandResult, + SandboxBackendExecSpec, + SandboxBackendFactory, + SandboxFsBridge, + SandboxFsStat, + SandboxBackendHandle, + SandboxBackendId, + SandboxBackendManager, + SandboxBackendRegistration, + SandboxBackendRuntimeInfo, + SandboxContext, + SandboxResolvedPath, +} from "../agents/sandbox.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawConfig } from "../config/config.js"; @@ -36,6 +53,12 @@ export type { } from "../infra/provider-usage.types.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { + getSandboxBackendFactory, + getSandboxBackendManager, + registerSandboxBackend, + requireSandboxBackendFactory, +} from "../agents/sandbox.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export { applyProviderDefaultModel, From 986b772a89d0fedb4e296545ec7224d19767c740 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:05:19 -0700 Subject: [PATCH 153/943] Status: scope JSON plugin preload to configured channels --- src/channels/config-presence.ts | 64 ++++++++++++++++----- src/cli/plugin-registry.test.ts | 95 ++++++++++++++++++++++++++++++++ src/cli/plugin-registry.ts | 49 ++++++++++++++-- src/commands/status.scan.test.ts | 12 ++-- src/commands/status.scan.ts | 2 +- 5 files changed, 197 insertions(+), 25 deletions(-) create mode 100644 src/cli/plugin-registry.test.ts diff --git a/src/channels/config-presence.ts b/src/channels/config-presence.ts index 792aa545a54..d9add345eeb 100644 --- a/src/channels/config-presence.ts +++ b/src/channels/config-presence.ts @@ -7,19 +7,19 @@ import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); const CHANNEL_ENV_PREFIXES = [ - "BLUEBUBBLES_", - "DISCORD_", - "GOOGLECHAT_", - "IRC_", - "LINE_", - "MATRIX_", - "MSTEAMS_", - "SIGNAL_", - "SLACK_", - "TELEGRAM_", - "WHATSAPP_", - "ZALOUSER_", - "ZALO_", + ["BLUEBUBBLES_", "bluebubbles"], + ["DISCORD_", "discord"], + ["GOOGLECHAT_", "googlechat"], + ["IRC_", "irc"], + ["LINE_", "line"], + ["MATRIX_", "matrix"], + ["MSTEAMS_", "msteams"], + ["SIGNAL_", "signal"], + ["SLACK_", "slack"], + ["TELEGRAM_", "telegram"], + ["WHATSAPP_", "whatsapp"], + ["ZALOUSER_", "zalouser"], + ["ZALO_", "zalo"], ] as const; function hasNonEmptyString(value: unknown): boolean { @@ -60,13 +60,49 @@ function hasWhatsAppAuthState(env: NodeJS.ProcessEnv): boolean { } } +export function listPotentialConfiguredChannelIds( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): string[] { + const configuredChannelIds = new Set(); + const channels = isRecord(cfg.channels) ? cfg.channels : null; + if (channels) { + for (const [key, value] of Object.entries(channels)) { + if (IGNORED_CHANNEL_CONFIG_KEYS.has(key)) { + continue; + } + if (recordHasKeys(value)) { + configuredChannelIds.add(key); + } + } + } + + for (const [key, value] of Object.entries(env)) { + if (!hasNonEmptyString(value)) { + continue; + } + for (const [prefix, channelId] of CHANNEL_ENV_PREFIXES) { + if (key.startsWith(prefix)) { + configuredChannelIds.add(channelId); + } + } + if (key === "TELEGRAM_BOT_TOKEN") { + configuredChannelIds.add("telegram"); + } + } + if (hasWhatsAppAuthState(env)) { + configuredChannelIds.add("whatsapp"); + } + return [...configuredChannelIds]; +} + function hasEnvConfiguredChannel(env: NodeJS.ProcessEnv): boolean { for (const [key, value] of Object.entries(env)) { if (!hasNonEmptyString(value)) { continue; } if ( - CHANNEL_ENV_PREFIXES.some((prefix) => key.startsWith(prefix)) || + CHANNEL_ENV_PREFIXES.some(([prefix]) => key.startsWith(prefix)) || key === "TELEGRAM_BOT_TOKEN" ) { return true; diff --git a/src/cli/plugin-registry.test.ts b/src/cli/plugin-registry.test.ts new file mode 100644 index 00000000000..f9751d5fed8 --- /dev/null +++ b/src/cli/plugin-registry.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), + resolveDefaultAgentId: vi.fn(() => "main"), + loadConfig: vi.fn(), + loadOpenClawPlugins: vi.fn(), + loadPluginManifestRegistry: vi.fn(), + getActivePluginRegistry: vi.fn(), +})); + +vi.mock("../agents/agent-scope.js", () => ({ + resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, + resolveDefaultAgentId: mocks.resolveDefaultAgentId, +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: mocks.loadConfig, +})); + +vi.mock("../plugins/loader.js", () => ({ + loadOpenClawPlugins: mocks.loadOpenClawPlugins, +})); + +vi.mock("../plugins/manifest-registry.js", () => ({ + loadPluginManifestRegistry: mocks.loadPluginManifestRegistry, +})); + +vi.mock("../plugins/runtime.js", () => ({ + getActivePluginRegistry: mocks.getActivePluginRegistry, +})); + +describe("ensurePluginRegistryLoaded", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.loadConfig.mockReturnValue({ + plugins: { enabled: true }, + channels: { telegram: { enabled: false } }, + }); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { id: "telegram", channels: ["telegram"] }, + { id: "slack", channels: ["slack"] }, + { id: "openai", channels: [] }, + ], + }); + mocks.getActivePluginRegistry.mockReturnValue({ + plugins: [], + channels: [], + tools: [], + }); + }); + + it("loads only configured channel plugins for configured-channels scope", async () => { + const { ensurePluginRegistryLoaded } = await import("./plugin-registry.js"); + + ensurePluginRegistryLoaded({ scope: "configured-channels" }); + + expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["telegram"], + }), + ); + }); + + it("reloads when escalating from configured-channels to channels", async () => { + mocks.getActivePluginRegistry + .mockReturnValueOnce({ + plugins: [], + channels: [], + tools: [], + }) + .mockReturnValue({ + plugins: [{ id: "telegram" }], + channels: [{ plugin: { id: "telegram" } }], + tools: [], + }); + + const { ensurePluginRegistryLoaded } = await import("./plugin-registry.js"); + + ensurePluginRegistryLoaded({ scope: "configured-channels" }); + ensurePluginRegistryLoaded({ scope: "channels" }); + + expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(2); + expect(mocks.loadOpenClawPlugins).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ onlyPluginIds: ["telegram"] }), + ); + expect(mocks.loadOpenClawPlugins).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ onlyPluginIds: ["telegram", "slack"] }), + ); + }); +}); diff --git a/src/cli/plugin-registry.ts b/src/cli/plugin-registry.ts index aad181eff7f..f51a57d7fda 100644 --- a/src/cli/plugin-registry.ts +++ b/src/cli/plugin-registry.ts @@ -1,4 +1,5 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listPotentialConfiguredChannelIds } from "../channels/config-presence.js"; import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; @@ -7,9 +8,22 @@ import { getActivePluginRegistry } from "../plugins/runtime.js"; import type { PluginLogger } from "../plugins/types.js"; const log = createSubsystemLogger("plugins"); -let pluginRegistryLoaded: "none" | "channels" | "all" = "none"; +let pluginRegistryLoaded: "none" | "configured-channels" | "channels" | "all" = "none"; -export type PluginRegistryScope = "channels" | "all"; +export type PluginRegistryScope = "configured-channels" | "channels" | "all"; + +function scopeRank(scope: typeof pluginRegistryLoaded): number { + switch (scope) { + case "none": + return 0; + case "configured-channels": + return 1; + case "channels": + return 2; + case "all": + return 3; + } +} function resolveChannelPluginIds(params: { config: ReturnType; @@ -25,15 +39,30 @@ function resolveChannelPluginIds(params: { .map((plugin) => plugin.id); } +function resolveConfiguredChannelPluginIds(params: { + config: ReturnType; + workspaceDir?: string; + env: NodeJS.ProcessEnv; +}): string[] { + const configuredChannelIds = new Set( + listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()), + ); + if (configuredChannelIds.size === 0) { + return []; + } + return resolveChannelPluginIds(params).filter((pluginId) => configuredChannelIds.has(pluginId)); +} + export function ensurePluginRegistryLoaded(options?: { scope?: PluginRegistryScope }): void { const scope = options?.scope ?? "all"; - if (pluginRegistryLoaded === "all" || pluginRegistryLoaded === scope) { + if (scopeRank(pluginRegistryLoaded) >= scopeRank(scope)) { return; } const active = getActivePluginRegistry(); // Tests (and callers) can pre-seed a registry (e.g. `test/setup.ts`); avoid // doing an expensive load when we already have plugins/channels/tools. if ( + pluginRegistryLoaded === "none" && active && (active.plugins.length > 0 || active.channels.length > 0 || active.tools.length > 0) ) { @@ -52,15 +81,23 @@ export function ensurePluginRegistryLoaded(options?: { scope?: PluginRegistrySco config, workspaceDir, logger, - ...(scope === "channels" + ...(scope === "configured-channels" ? { - onlyPluginIds: resolveChannelPluginIds({ + onlyPluginIds: resolveConfiguredChannelPluginIds({ config, workspaceDir, env: process.env, }), } - : {}), + : scope === "channels" + ? { + onlyPluginIds: resolveChannelPluginIds({ + config, + workspaceDir, + env: process.env, + }), + } + : {}), }); pluginRegistryLoaded = scope; } diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 55f323f0b4a..122e10076bf 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -194,7 +194,7 @@ describe("scanStatus", () => { expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled(); }); - it("preloads channel plugins for status --json when channel config exists", async () => { + it("preloads configured channel plugins for status --json when channel config exists", async () => { mocks.readBestEffortConfig.mockResolvedValue({ session: {}, plugins: { enabled: false }, @@ -245,7 +245,9 @@ describe("scanStatus", () => { await scanStatus({ json: true }, {} as never); - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "channels" }); + expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ + scope: "configured-channels", + }); expect(mocks.probeGateway).toHaveBeenCalledWith( expect.objectContaining({ detailLevel: "presence" }), ); @@ -254,7 +256,7 @@ describe("scanStatus", () => { ); }); - it("preloads channel plugins for status --json when channel auth is env-only", async () => { + it("preloads configured channel plugins for status --json when channel auth is env-only", async () => { const prevMatrixToken = process.env.MATRIX_ACCESS_TOKEN; process.env.MATRIX_ACCESS_TOKEN = "token"; mocks.readBestEffortConfig.mockResolvedValue({ @@ -313,6 +315,8 @@ describe("scanStatus", () => { } } - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "channels" }); + expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ + scope: "configured-channels", + }); }); }); diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 88dd21e7177..7f1380964d5 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -202,7 +202,7 @@ async function scanStatusJsonFast(opts: { }); if (hasPotentialConfiguredChannels(cfg)) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); - ensurePluginRegistryLoaded({ scope: "channels" }); + ensurePluginRegistryLoaded({ scope: "configured-channels" }); } const osSummary = resolveOsSummary(); const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; From f71f44576aa0172055ba3d3f0ee86f9c9f6cd99e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:10:43 -0700 Subject: [PATCH 154/943] Status: lazy-load read-only account inspectors --- src/channels/plugins/status.ts | 12 ++--- ...ad-only-account-inspect.discord.runtime.ts | 4 ++ ...read-only-account-inspect.slack.runtime.ts | 4 ++ ...d-only-account-inspect.telegram.runtime.ts | 4 ++ src/channels/read-only-account-inspect.ts | 50 ++++++++++++------- src/commands/channel-account-context.ts | 4 +- src/commands/health.ts | 12 ++--- src/commands/status-all/channels.ts | 14 ++++-- src/infra/channel-summary.ts | 14 ++++-- src/security/audit-channel.ts | 10 ++-- 10 files changed, 79 insertions(+), 49 deletions(-) create mode 100644 src/channels/read-only-account-inspect.discord.runtime.ts create mode 100644 src/channels/read-only-account-inspect.slack.runtime.ts create mode 100644 src/channels/read-only-account-inspect.telegram.runtime.ts diff --git a/src/channels/plugins/status.ts b/src/channels/plugins/status.ts index 689c50c6710..983ba23be33 100644 --- a/src/channels/plugins/status.ts +++ b/src/channels/plugins/status.ts @@ -41,17 +41,17 @@ async function buildSnapshotFromAccount(params: { }; } -function inspectChannelAccount(params: { +async function inspectChannelAccount(params: { plugin: ChannelPlugin; cfg: OpenClawConfig; accountId: string; -}): ResolvedAccount | null { +}): Promise { return (params.plugin.config.inspectAccount?.(params.cfg, params.accountId) ?? - inspectReadOnlyChannelAccount({ + (await inspectReadOnlyChannelAccount({ channelId: params.plugin.id, cfg: params.cfg, accountId: params.accountId, - })) as ResolvedAccount | null; + }))) as ResolvedAccount | null; } export async function buildReadOnlySourceChannelAccountSnapshot(params: { @@ -62,7 +62,7 @@ export async function buildReadOnlySourceChannelAccountSnapshot probe?: unknown; audit?: unknown; }): Promise { - const inspectedAccount = inspectChannelAccount(params); + const inspectedAccount = await inspectChannelAccount(params); if (!inspectedAccount) { return null; } @@ -80,7 +80,7 @@ export async function buildChannelAccountSnapshot(params: { probe?: unknown; audit?: unknown; }): Promise { - const inspectedAccount = inspectChannelAccount(params); + const inspectedAccount = await inspectChannelAccount(params); const account = inspectedAccount ?? params.plugin.config.resolveAccount(params.cfg, params.accountId); return await buildSnapshotFromAccount({ diff --git a/src/channels/read-only-account-inspect.discord.runtime.ts b/src/channels/read-only-account-inspect.discord.runtime.ts new file mode 100644 index 00000000000..aed3283b7a2 --- /dev/null +++ b/src/channels/read-only-account-inspect.discord.runtime.ts @@ -0,0 +1,4 @@ +export { + inspectDiscordAccount, + type InspectedDiscordAccount, +} from "../../extensions/discord/src/account-inspect.js"; diff --git a/src/channels/read-only-account-inspect.slack.runtime.ts b/src/channels/read-only-account-inspect.slack.runtime.ts new file mode 100644 index 00000000000..6d0e0a10b29 --- /dev/null +++ b/src/channels/read-only-account-inspect.slack.runtime.ts @@ -0,0 +1,4 @@ +export { + inspectSlackAccount, + type InspectedSlackAccount, +} from "../../extensions/slack/src/account-inspect.js"; diff --git a/src/channels/read-only-account-inspect.telegram.runtime.ts b/src/channels/read-only-account-inspect.telegram.runtime.ts new file mode 100644 index 00000000000..07866b9d450 --- /dev/null +++ b/src/channels/read-only-account-inspect.telegram.runtime.ts @@ -0,0 +1,4 @@ +export { + inspectTelegramAccount, + type InspectedTelegramAccount, +} from "../../extensions/telegram/src/account-inspect.js"; diff --git a/src/channels/read-only-account-inspect.ts b/src/channels/read-only-account-inspect.ts index c8d99a3a42e..d26c1c77f55 100644 --- a/src/channels/read-only-account-inspect.ts +++ b/src/channels/read-only-account-inspect.ts @@ -1,41 +1,55 @@ -import { - inspectDiscordAccount, - type InspectedDiscordAccount, -} from "../../extensions/discord/src/account-inspect.js"; -import { - inspectSlackAccount, - type InspectedSlackAccount, -} from "../../extensions/slack/src/account-inspect.js"; -import { - inspectTelegramAccount, - type InspectedTelegramAccount, -} from "../../extensions/telegram/src/account-inspect.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ChannelId } from "./plugins/types.js"; -export type ReadOnlyInspectedAccount = - | InspectedDiscordAccount - | InspectedSlackAccount - | InspectedTelegramAccount; +type DiscordInspectModule = typeof import("./read-only-account-inspect.discord.runtime.js"); +type SlackInspectModule = typeof import("./read-only-account-inspect.slack.runtime.js"); +type TelegramInspectModule = typeof import("./read-only-account-inspect.telegram.runtime.js"); -export function inspectReadOnlyChannelAccount(params: { +let discordInspectModulePromise: Promise | undefined; +let slackInspectModulePromise: Promise | undefined; +let telegramInspectModulePromise: Promise | undefined; + +function loadDiscordInspectModule() { + discordInspectModulePromise ??= import("./read-only-account-inspect.discord.runtime.js"); + return discordInspectModulePromise; +} + +function loadSlackInspectModule() { + slackInspectModulePromise ??= import("./read-only-account-inspect.slack.runtime.js"); + return slackInspectModulePromise; +} + +function loadTelegramInspectModule() { + telegramInspectModulePromise ??= import("./read-only-account-inspect.telegram.runtime.js"); + return telegramInspectModulePromise; +} + +export type ReadOnlyInspectedAccount = + | Awaited> + | Awaited> + | Awaited>; + +export async function inspectReadOnlyChannelAccount(params: { channelId: ChannelId; cfg: OpenClawConfig; accountId?: string | null; -}): ReadOnlyInspectedAccount | null { +}): Promise { if (params.channelId === "discord") { + const { inspectDiscordAccount } = await loadDiscordInspectModule(); return inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId, }); } if (params.channelId === "slack") { + const { inspectSlackAccount } = await loadSlackInspectModule(); return inspectSlackAccount({ cfg: params.cfg, accountId: params.accountId, }); } if (params.channelId === "telegram") { + const { inspectTelegramAccount } = await loadTelegramInspectModule(); return inspectTelegramAccount({ cfg: params.cfg, accountId: params.accountId, diff --git a/src/commands/channel-account-context.ts b/src/commands/channel-account-context.ts index c997ec3e18a..a9f12974b06 100644 --- a/src/commands/channel-account-context.ts +++ b/src/commands/channel-account-context.ts @@ -79,11 +79,11 @@ export async function resolveDefaultChannelAccountContext( const inspected = plugin.config.inspectAccount?.(cfg, defaultAccountId) ?? - inspectReadOnlyChannelAccount({ + (await inspectReadOnlyChannelAccount({ channelId: plugin.id, cfg, accountId: defaultAccountId, - }); + })); let account = inspected; if (!account) { diff --git a/src/commands/health.ts b/src/commands/health.ts index 0e54eebadc7..ddfc308bda4 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -165,18 +165,14 @@ const buildSessionSummary = (storePath: string) => { const asRecord = (value: unknown): Record | null => value && typeof value === "object" ? (value as Record) : null; -function inspectHealthAccount( - plugin: ChannelPlugin, - cfg: OpenClawConfig, - accountId: string, -): unknown { +async function inspectHealthAccount(plugin: ChannelPlugin, cfg: OpenClawConfig, accountId: string) { return ( plugin.config.inspectAccount?.(cfg, accountId) ?? - inspectReadOnlyChannelAccount({ + (await inspectReadOnlyChannelAccount({ channelId: plugin.id, cfg, accountId, - }) + })) ); } @@ -206,7 +202,7 @@ async function resolveHealthAccountContext(params: { diagnostics.push( `${params.plugin.id}:${params.accountId}: failed to resolve account (${formatErrorMessage(error)}).`, ); - account = inspectHealthAccount(params.plugin, params.cfg, params.accountId); + account = await inspectHealthAccount(params.plugin, params.cfg, params.accountId); } if (!account) { diff --git a/src/commands/status-all/channels.ts b/src/commands/status-all/channels.ts index cf3a67a99b5..27e0eff43c6 100644 --- a/src/commands/status-all/channels.ts +++ b/src/commands/status-all/channels.ts @@ -91,14 +91,18 @@ function formatTokenHint(token: string, opts: { showSecrets: boolean }): string return `${head}…${tail} · len ${t.length}`; } -function inspectChannelAccount(plugin: ChannelPlugin, cfg: OpenClawConfig, accountId: string) { +async function inspectChannelAccount( + plugin: ChannelPlugin, + cfg: OpenClawConfig, + accountId: string, +) { return ( plugin.config.inspectAccount?.(cfg, accountId) ?? - inspectReadOnlyChannelAccount({ + (await inspectReadOnlyChannelAccount({ channelId: plugin.id, cfg, accountId, - }) + })) ); } @@ -106,8 +110,8 @@ async function resolveChannelAccountRow( params: ResolvedChannelAccountRowParams, ): Promise { const { plugin, cfg, sourceConfig, accountId } = params; - const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId); - const resolvedInspectedAccount = inspectChannelAccount(plugin, cfg, accountId); + const sourceInspectedAccount = await inspectChannelAccount(plugin, sourceConfig, accountId); + const resolvedInspectedAccount = await inspectChannelAccount(plugin, cfg, accountId); const resolvedInspection = resolvedInspectedAccount as { enabled?: boolean; configured?: boolean; diff --git a/src/infra/channel-summary.ts b/src/infra/channel-summary.ts index 08fd35d9327..d537b5eb317 100644 --- a/src/infra/channel-summary.ts +++ b/src/infra/channel-summary.ts @@ -105,14 +105,18 @@ const buildAccountDetails = (params: { return details; }; -function inspectChannelAccount(plugin: ChannelPlugin, cfg: OpenClawConfig, accountId: string) { +async function inspectChannelAccount( + plugin: ChannelPlugin, + cfg: OpenClawConfig, + accountId: string, +) { return ( plugin.config.inspectAccount?.(cfg, accountId) ?? - inspectReadOnlyChannelAccount({ + (await inspectReadOnlyChannelAccount({ channelId: plugin.id, cfg, accountId, - }) + })) ); } @@ -135,8 +139,8 @@ export async function buildChannelSummary( const entries: ChannelAccountEntry[] = []; for (const accountId of resolvedAccountIds) { - const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId); - const resolvedInspectedAccount = inspectChannelAccount(plugin, effective, accountId); + const sourceInspectedAccount = await inspectChannelAccount(plugin, sourceConfig, accountId); + const resolvedInspectedAccount = await inspectChannelAccount(plugin, effective, accountId); const resolvedInspection = resolvedInspectedAccount as { enabled?: boolean; configured?: boolean; diff --git a/src/security/audit-channel.ts b/src/security/audit-channel.ts index bf501cf659b..ce1484f6513 100644 --- a/src/security/audit-channel.ts +++ b/src/security/audit-channel.ts @@ -144,17 +144,17 @@ export async function collectChannelSecurityFindings(params: { const findings: SecurityAuditFinding[] = []; const sourceConfig = params.sourceConfig ?? params.cfg; - const inspectChannelAccount = ( + const inspectChannelAccount = async ( plugin: (typeof params.plugins)[number], cfg: OpenClawConfig, accountId: string, ) => plugin.config.inspectAccount?.(cfg, accountId) ?? - inspectReadOnlyChannelAccount({ + (await inspectReadOnlyChannelAccount({ channelId: plugin.id, cfg, accountId, - }); + })); const asAccountRecord = (value: unknown): Record | null => value && typeof value === "object" && !Array.isArray(value) @@ -166,8 +166,8 @@ export async function collectChannelSecurityFindings(params: { accountId: string, ) => { const diagnostics: string[] = []; - const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId); - const resolvedInspectedAccount = inspectChannelAccount(plugin, params.cfg, accountId); + const sourceInspectedAccount = await inspectChannelAccount(plugin, sourceConfig, accountId); + const resolvedInspectedAccount = await inspectChannelAccount(plugin, params.cfg, accountId); const sourceInspection = sourceInspectedAccount as { enabled?: boolean; configured?: boolean; From 8ab01c5c9394d7f85a2b258b0bd9b2824b85ac7c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:02:24 -0700 Subject: [PATCH 155/943] refactor(core): land plugin auth and startup cleanup --- docs/concepts/model-providers.md | 2 + docs/plugins/manifest.md | 6 + docs/tools/plugin.md | 7 ++ extensions/anthropic/openclaw.plugin.json | 3 + extensions/byteplus/openclaw.plugin.json | 3 + .../openclaw.plugin.json | 3 + extensions/feishu/src/onboarding.ts | 7 ++ extensions/github-copilot/index.ts | 7 +- .../github-copilot/openclaw.plugin.json | 3 + .../github-copilot/token.test.ts | 7 +- .../github-copilot/token.ts | 4 +- .../github-copilot/usage.test.ts | 7 +- .../github-copilot/usage.ts | 9 +- extensions/huggingface/openclaw.plugin.json | 3 + extensions/kilocode/openclaw.plugin.json | 3 + extensions/kimi-coding/openclaw.plugin.json | 3 + extensions/mattermost/src/setup-surface.ts | 2 +- extensions/minimax/index.ts | 1 + extensions/minimax/openclaw.plugin.json | 4 + extensions/mistral/openclaw.plugin.json | 3 + extensions/modelstudio/openclaw.plugin.json | 3 + extensions/moonshot/openclaw.plugin.json | 3 + extensions/nvidia/openclaw.plugin.json | 3 + extensions/ollama/openclaw.plugin.json | 3 + extensions/openai/openclaw.plugin.json | 3 + extensions/opencode-go/openclaw.plugin.json | 3 + extensions/opencode/openclaw.plugin.json | 3 + extensions/openrouter/openclaw.plugin.json | 3 + extensions/qianfan/openclaw.plugin.json | 3 + extensions/qwen-portal-auth/index.ts | 1 + .../qwen-portal-auth/openclaw.plugin.json | 3 + extensions/sglang/openclaw.plugin.json | 3 + extensions/synthetic/openclaw.plugin.json | 3 + extensions/together/openclaw.plugin.json | 3 + extensions/venice/openclaw.plugin.json | 3 + .../vercel-ai-gateway/openclaw.plugin.json | 3 + extensions/vllm/openclaw.plugin.json | 3 + extensions/volcengine/openclaw.plugin.json | 3 + extensions/xiaomi/openclaw.plugin.json | 3 + extensions/zai/openclaw.plugin.json | 3 + src/agents/model-auth-env-vars.ts | 49 ++------- src/agents/model-auth.profiles.test.ts | 41 +++++++ src/agents/model-auth.ts | 4 +- ...fault-baseurl-token-exchange-fails.test.ts | 2 +- ...pi-agent.auth-profile-rotation.e2e.test.ts | 2 +- src/channels/plugins/onboarding-types.ts | 7 +- src/commands/channel-setup/registry.ts | 9 +- src/commands/channels/add.ts | 3 +- src/commands/onboard-channels.ts | 22 ++-- src/daemon/program-args.test.ts | 32 ++++++ src/daemon/program-args.ts | 6 +- src/index.test.ts | 46 ++++++++ src/index.ts | 94 +++++----------- src/infra/gateway-process-argv.test.ts | 1 + src/infra/gateway-process-argv.ts | 1 + src/infra/provider-usage.auth.ts | 103 ------------------ src/infra/provider-usage.fetch.ts | 1 - src/infra/provider-usage.load.plugin.test.ts | 2 +- src/infra/provider-usage.load.ts | 52 +-------- src/library.ts | 48 ++++++++ .../bundled-provider-auth-env-vars.test.ts | 22 ++++ src/plugins/bundled-provider-auth-env-vars.ts | 91 ++++++++++++++++ src/plugins/config-state.test.ts | 5 + src/plugins/config-state.ts | 1 + src/plugins/loader.ts | 98 ++++++++++++----- src/plugins/manifest-registry.test.ts | 22 ++++ src/plugins/manifest-registry.ts | 2 + src/plugins/manifest.ts | 22 ++++ src/plugins/provider-runtime.test.ts | 13 ++- src/plugins/provider-runtime.ts | 14 ++- src/plugins/providers.test.ts | 26 ++++- src/plugins/providers.ts | 28 +++++ src/plugins/types.ts | 15 ++- src/secrets/provider-env-vars.test.ts | 6 +- src/secrets/provider-env-vars.ts | 84 +++++++------- 75 files changed, 736 insertions(+), 383 deletions(-) create mode 100644 extensions/feishu/src/onboarding.ts rename src/providers/github-copilot-token.test.ts => extensions/github-copilot/token.test.ts (91%) rename src/providers/github-copilot-token.ts => extensions/github-copilot/token.ts (97%) rename src/infra/provider-usage.fetch.copilot.test.ts => extensions/github-copilot/usage.test.ts (93%) rename src/infra/provider-usage.fetch.copilot.ts => extensions/github-copilot/usage.ts (83%) create mode 100644 src/index.test.ts create mode 100644 src/library.ts create mode 100644 src/plugins/bundled-provider-auth-env-vars.test.ts create mode 100644 src/plugins/bundled-provider-auth-env-vars.ts diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 23fe7edcd1d..aa4b90fd41f 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -19,6 +19,8 @@ For model selection rules, see [/concepts/models](/concepts/models). - Provider plugins can inject model catalogs via `registerProvider({ catalog })`; OpenClaw merges that output into `models.providers` before writing `models.json`. +- Provider manifests can declare `providerAuthEnvVars` so generic env-based + auth probes do not need to load plugin runtime. - Provider plugins can also own provider runtime behavior via `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 9c266744b71..01d5e0d3578 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -56,6 +56,9 @@ Optional keys: - `kind` (string): plugin kind (examples: `"memory"`, `"context-engine"`). - `channels` (array): channel ids registered by this plugin (example: `["matrix"]`). - `providers` (array): provider ids registered by this plugin. +- `providerAuthEnvVars` (object): auth env vars keyed by provider id. Use this + when OpenClaw should resolve provider credentials from env without loading + plugin runtime first. - `skills` (array): skill directories to load (relative to the plugin root). - `name` (string): display name for the plugin. - `description` (string): short plugin summary. @@ -84,6 +87,9 @@ Optional keys: - The manifest is **required for native OpenClaw plugins**, including local filesystem loads. - Runtime still loads the plugin module separately; the manifest is only for discovery + validation. +- `providerAuthEnvVars` is the cheap metadata path for auth probes, env-marker + validation, and similar provider-auth surfaces that should not boot plugin + runtime just to inspect env names. - Exclusive plugin kinds are selected through `plugins.slots.*`. - `kind: "memory"` is selected by `plugins.slots.memory`. - `kind: "context-engine"` is selected by `plugins.slots.contextEngine` diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 3987ff6a7eb..976c10d0671 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -217,6 +217,8 @@ Tool authoring guide: [Plugin agent tools](/plugins/agent-tools). Provider plugins now have two layers: +- manifest metadata: `providerAuthEnvVars` for cheap env-auth lookup before + runtime load - config-time hooks: `catalog` / legacy `discovery` - runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `isCacheTtlEligible`, `buildMissingAuthMessage`, `suppressBuiltInModel`, `augmentModelCatalog`, `prepareRuntimeAuth`, `resolveUsageAuth`, `fetchUsageSnapshot` @@ -224,6 +226,11 @@ OpenClaw still owns the generic agent loop, failover, transcript handling, and tool policy. These hooks are the seam for provider-specific behavior without needing a whole custom inference transport. +Use manifest `providerAuthEnvVars` when the provider has env-based credentials +that generic auth/status/model-picker paths should see without loading plugin +runtime. Keep provider runtime `envVars` for operator-facing hints such as +onboarding labels or OAuth client-id/client-secret setup vars. + ### Hook order For model/provider plugins, OpenClaw uses hooks in this rough order: diff --git a/extensions/anthropic/openclaw.plugin.json b/extensions/anthropic/openclaw.plugin.json index 5342e849e52..aec972801f8 100644 --- a/extensions/anthropic/openclaw.plugin.json +++ b/extensions/anthropic/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "anthropic", "providers": ["anthropic"], + "providerAuthEnvVars": { + "anthropic": ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/byteplus/openclaw.plugin.json b/extensions/byteplus/openclaw.plugin.json index 8885280bf32..abef4351a48 100644 --- a/extensions/byteplus/openclaw.plugin.json +++ b/extensions/byteplus/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "byteplus", "providers": ["byteplus", "byteplus-plan"], + "providerAuthEnvVars": { + "byteplus": ["BYTEPLUS_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/cloudflare-ai-gateway/openclaw.plugin.json b/extensions/cloudflare-ai-gateway/openclaw.plugin.json index fc7a41f77bb..ca7810e1fd2 100644 --- a/extensions/cloudflare-ai-gateway/openclaw.plugin.json +++ b/extensions/cloudflare-ai-gateway/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "cloudflare-ai-gateway", "providers": ["cloudflare-ai-gateway"], + "providerAuthEnvVars": { + "cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/onboarding.ts new file mode 100644 index 00000000000..ff8f563cf65 --- /dev/null +++ b/extensions/feishu/src/onboarding.ts @@ -0,0 +1,7 @@ +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { feishuPlugin } from "./channel.js"; + +export const feishuOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: feishuPlugin, + wizard: feishuPlugin.setupWizard!, +}); diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 19114472830..038ed70aec9 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -8,11 +8,8 @@ import { listProfilesForProvider } from "../../src/agents/auth-profiles/profiles import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js"; import { normalizeModelCompat } from "../../src/agents/model-compat.js"; import { coerceSecretRef } from "../../src/config/types.secrets.js"; -import { fetchCopilotUsage } from "../../src/infra/provider-usage.fetch.js"; -import { - DEFAULT_COPILOT_API_BASE_URL, - resolveCopilotApiToken, -} from "../../src/providers/github-copilot-token.js"; +import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } from "./token.js"; +import { fetchCopilotUsage } from "./usage.js"; const PROVIDER_ID = "github-copilot"; const COPILOT_ENV_VARS = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]; diff --git a/extensions/github-copilot/openclaw.plugin.json b/extensions/github-copilot/openclaw.plugin.json index ec3f8690eee..a6cb5b7f4b5 100644 --- a/extensions/github-copilot/openclaw.plugin.json +++ b/extensions/github-copilot/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "github-copilot", "providers": ["github-copilot"], + "providerAuthEnvVars": { + "github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/src/providers/github-copilot-token.test.ts b/extensions/github-copilot/token.test.ts similarity index 91% rename from src/providers/github-copilot-token.test.ts rename to extensions/github-copilot/token.test.ts index 4f7664364a0..8aa489e7a8b 100644 --- a/src/providers/github-copilot-token.test.ts +++ b/extensions/github-copilot/token.test.ts @@ -1,8 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - deriveCopilotApiBaseUrlFromToken, - resolveCopilotApiToken, -} from "./github-copilot-token.js"; +import { deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken } from "./token.js"; describe("github-copilot token", () => { const loadJsonFile = vi.fn(); @@ -58,7 +55,7 @@ describe("github-copilot token", () => { }), }); - const { resolveCopilotApiToken } = await import("./github-copilot-token.js"); + const { resolveCopilotApiToken } = await import("./token.js"); const res = await resolveCopilotApiToken({ githubToken: "gh", diff --git a/src/providers/github-copilot-token.ts b/extensions/github-copilot/token.ts similarity index 97% rename from src/providers/github-copilot-token.ts rename to extensions/github-copilot/token.ts index a5d9a6b1e8e..afb1eb03b61 100644 --- a/src/providers/github-copilot-token.ts +++ b/extensions/github-copilot/token.ts @@ -1,6 +1,6 @@ import path from "node:path"; -import { resolveStateDir } from "../config/paths.js"; -import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; +import { resolveStateDir } from "../../src/config/paths.js"; +import { loadJsonFile, saveJsonFile } from "../../src/infra/json-file.js"; const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"; diff --git a/src/infra/provider-usage.fetch.copilot.test.ts b/extensions/github-copilot/usage.test.ts similarity index 93% rename from src/infra/provider-usage.fetch.copilot.test.ts rename to extensions/github-copilot/usage.test.ts index 0abfd5f782f..b4044c7f5f9 100644 --- a/src/infra/provider-usage.fetch.copilot.test.ts +++ b/extensions/github-copilot/usage.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js"; -import { fetchCopilotUsage } from "./provider-usage.fetch.copilot.js"; +import { + createProviderUsageFetch, + makeResponse, +} from "../../src/test-utils/provider-usage-fetch.js"; +import { fetchCopilotUsage } from "./usage.js"; describe("fetchCopilotUsage", () => { it("returns HTTP errors for failed requests", async () => { diff --git a/src/infra/provider-usage.fetch.copilot.ts b/extensions/github-copilot/usage.ts similarity index 83% rename from src/infra/provider-usage.fetch.copilot.ts rename to extensions/github-copilot/usage.ts index 40d4adcd3aa..9035027890c 100644 --- a/src/infra/provider-usage.fetch.copilot.ts +++ b/extensions/github-copilot/usage.ts @@ -1,6 +1,9 @@ -import { buildUsageHttpErrorSnapshot, fetchJson } from "./provider-usage.fetch.shared.js"; -import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js"; -import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js"; +import { + buildUsageHttpErrorSnapshot, + fetchJson, +} from "../../src/infra/provider-usage.fetch.shared.js"; +import { clampPercent, PROVIDER_LABELS } from "../../src/infra/provider-usage.shared.js"; +import type { ProviderUsageSnapshot, UsageWindow } from "../../src/infra/provider-usage.types.js"; type CopilotUsageResponse = { quota_snapshots?: { diff --git a/extensions/huggingface/openclaw.plugin.json b/extensions/huggingface/openclaw.plugin.json index 4b68bcedb26..67a34124d0a 100644 --- a/extensions/huggingface/openclaw.plugin.json +++ b/extensions/huggingface/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "huggingface", "providers": ["huggingface"], + "providerAuthEnvVars": { + "huggingface": ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/kilocode/openclaw.plugin.json b/extensions/kilocode/openclaw.plugin.json index ec078c33ab7..6e3e39aec27 100644 --- a/extensions/kilocode/openclaw.plugin.json +++ b/extensions/kilocode/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "kilocode", "providers": ["kilocode"], + "providerAuthEnvVars": { + "kilocode": ["KILOCODE_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/kimi-coding/openclaw.plugin.json b/extensions/kimi-coding/openclaw.plugin.json index 8874fb6501b..0664e7ae6df 100644 --- a/extensions/kimi-coding/openclaw.plugin.json +++ b/extensions/kimi-coding/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "kimi-coding", "providers": ["kimi-coding"], + "providerAuthEnvVars": { + "kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/mattermost/src/setup-surface.ts b/extensions/mattermost/src/setup-surface.ts index e1be50e662a..13b69542d02 100644 --- a/extensions/mattermost/src/setup-surface.ts +++ b/extensions/mattermost/src/setup-surface.ts @@ -1,6 +1,6 @@ import { - DEFAULT_ACCOUNT_ID, applySetupAccountConfigPatch, + DEFAULT_ACCOUNT_ID, hasConfiguredSecretInput, type OpenClawConfig, } from "openclaw/plugin-sdk/mattermost"; diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index 969868986f0..e99f5bf15b2 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -175,6 +175,7 @@ const minimaxPlugin = { id: PORTAL_PROVIDER_ID, label: PROVIDER_LABEL, docsPath: "/providers/minimax", + envVars: ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"], catalog: { run: async (ctx) => resolvePortalCatalog(ctx), }, diff --git a/extensions/minimax/openclaw.plugin.json b/extensions/minimax/openclaw.plugin.json index 32d8be58bf5..8934580b36b 100644 --- a/extensions/minimax/openclaw.plugin.json +++ b/extensions/minimax/openclaw.plugin.json @@ -1,6 +1,10 @@ { "id": "minimax", "providers": ["minimax", "minimax-portal"], + "providerAuthEnvVars": { + "minimax": ["MINIMAX_API_KEY"], + "minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/mistral/openclaw.plugin.json b/extensions/mistral/openclaw.plugin.json index dd38282811b..480c09417d0 100644 --- a/extensions/mistral/openclaw.plugin.json +++ b/extensions/mistral/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "mistral", "providers": ["mistral"], + "providerAuthEnvVars": { + "mistral": ["MISTRAL_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/modelstudio/openclaw.plugin.json b/extensions/modelstudio/openclaw.plugin.json index 1a8d9e71c75..5cc87ad1b54 100644 --- a/extensions/modelstudio/openclaw.plugin.json +++ b/extensions/modelstudio/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "modelstudio", "providers": ["modelstudio"], + "providerAuthEnvVars": { + "modelstudio": ["MODELSTUDIO_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/moonshot/openclaw.plugin.json b/extensions/moonshot/openclaw.plugin.json index e02cb3d21c5..542ae46fead 100644 --- a/extensions/moonshot/openclaw.plugin.json +++ b/extensions/moonshot/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "moonshot", "providers": ["moonshot"], + "providerAuthEnvVars": { + "moonshot": ["MOONSHOT_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/nvidia/openclaw.plugin.json b/extensions/nvidia/openclaw.plugin.json index 268bfa2dafd..3b46534911b 100644 --- a/extensions/nvidia/openclaw.plugin.json +++ b/extensions/nvidia/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "nvidia", "providers": ["nvidia"], + "providerAuthEnvVars": { + "nvidia": ["NVIDIA_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/ollama/openclaw.plugin.json b/extensions/ollama/openclaw.plugin.json index 3df1002d1ac..b644e105b84 100644 --- a/extensions/ollama/openclaw.plugin.json +++ b/extensions/ollama/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "ollama", "providers": ["ollama"], + "providerAuthEnvVars": { + "ollama": ["OLLAMA_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/openai/openclaw.plugin.json b/extensions/openai/openclaw.plugin.json index 480e80a59ce..4b0ae0efc31 100644 --- a/extensions/openai/openclaw.plugin.json +++ b/extensions/openai/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "openai", "providers": ["openai", "openai-codex"], + "providerAuthEnvVars": { + "openai": ["OPENAI_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/opencode-go/openclaw.plugin.json b/extensions/opencode-go/openclaw.plugin.json index 09d48bcf314..d264f4acdb6 100644 --- a/extensions/opencode-go/openclaw.plugin.json +++ b/extensions/opencode-go/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "opencode-go", "providers": ["opencode-go"], + "providerAuthEnvVars": { + "opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/opencode/openclaw.plugin.json b/extensions/opencode/openclaw.plugin.json index f61e9b99b67..68608e6abd1 100644 --- a/extensions/opencode/openclaw.plugin.json +++ b/extensions/opencode/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "opencode", "providers": ["opencode"], + "providerAuthEnvVars": { + "opencode": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/openrouter/openclaw.plugin.json b/extensions/openrouter/openclaw.plugin.json index 7e7840cb1c9..84069b8129b 100644 --- a/extensions/openrouter/openclaw.plugin.json +++ b/extensions/openrouter/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "openrouter", "providers": ["openrouter"], + "providerAuthEnvVars": { + "openrouter": ["OPENROUTER_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/qianfan/openclaw.plugin.json b/extensions/qianfan/openclaw.plugin.json index 9bd75d78c4b..5070b7a65b7 100644 --- a/extensions/qianfan/openclaw.plugin.json +++ b/extensions/qianfan/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "qianfan", "providers": ["qianfan"], + "providerAuthEnvVars": { + "qianfan": ["QIANFAN_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/qwen-portal-auth/index.ts b/extensions/qwen-portal-auth/index.ts index 919fa927e57..446070b0a6b 100644 --- a/extensions/qwen-portal-auth/index.ts +++ b/extensions/qwen-portal-auth/index.ts @@ -94,6 +94,7 @@ const qwenPortalPlugin = { label: PROVIDER_LABEL, docsPath: "/providers/qwen", aliases: ["qwen"], + envVars: ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], catalog: { run: async (ctx: ProviderCatalogContext) => resolveCatalog(ctx), }, diff --git a/extensions/qwen-portal-auth/openclaw.plugin.json b/extensions/qwen-portal-auth/openclaw.plugin.json index be200d11f04..1f5a5deb0b5 100644 --- a/extensions/qwen-portal-auth/openclaw.plugin.json +++ b/extensions/qwen-portal-auth/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "qwen-portal-auth", "providers": ["qwen-portal"], + "providerAuthEnvVars": { + "qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/sglang/openclaw.plugin.json b/extensions/sglang/openclaw.plugin.json index 161ea4c635a..8d5840c0fdf 100644 --- a/extensions/sglang/openclaw.plugin.json +++ b/extensions/sglang/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "sglang", "providers": ["sglang"], + "providerAuthEnvVars": { + "sglang": ["SGLANG_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/synthetic/openclaw.plugin.json b/extensions/synthetic/openclaw.plugin.json index fab1326ca34..54c12a19e4c 100644 --- a/extensions/synthetic/openclaw.plugin.json +++ b/extensions/synthetic/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "synthetic", "providers": ["synthetic"], + "providerAuthEnvVars": { + "synthetic": ["SYNTHETIC_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/together/openclaw.plugin.json b/extensions/together/openclaw.plugin.json index 2a868251f34..ea3ae237fa2 100644 --- a/extensions/together/openclaw.plugin.json +++ b/extensions/together/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "together", "providers": ["together"], + "providerAuthEnvVars": { + "together": ["TOGETHER_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/venice/openclaw.plugin.json b/extensions/venice/openclaw.plugin.json index 6262595509e..a84a0e7b669 100644 --- a/extensions/venice/openclaw.plugin.json +++ b/extensions/venice/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "venice", "providers": ["venice"], + "providerAuthEnvVars": { + "venice": ["VENICE_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/vercel-ai-gateway/openclaw.plugin.json b/extensions/vercel-ai-gateway/openclaw.plugin.json index 14f4a214605..47037724c36 100644 --- a/extensions/vercel-ai-gateway/openclaw.plugin.json +++ b/extensions/vercel-ai-gateway/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "vercel-ai-gateway", "providers": ["vercel-ai-gateway"], + "providerAuthEnvVars": { + "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/vllm/openclaw.plugin.json b/extensions/vllm/openclaw.plugin.json index 5a9f9a778ee..6ab01cb5e89 100644 --- a/extensions/vllm/openclaw.plugin.json +++ b/extensions/vllm/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "vllm", "providers": ["vllm"], + "providerAuthEnvVars": { + "vllm": ["VLLM_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/volcengine/openclaw.plugin.json b/extensions/volcengine/openclaw.plugin.json index 0773577aef9..2b5e54ff013 100644 --- a/extensions/volcengine/openclaw.plugin.json +++ b/extensions/volcengine/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "volcengine", "providers": ["volcengine", "volcengine-plan"], + "providerAuthEnvVars": { + "volcengine": ["VOLCANO_ENGINE_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/xiaomi/openclaw.plugin.json b/extensions/xiaomi/openclaw.plugin.json index 78c758c6571..4f0c03c280f 100644 --- a/extensions/xiaomi/openclaw.plugin.json +++ b/extensions/xiaomi/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "xiaomi", "providers": ["xiaomi"], + "providerAuthEnvVars": { + "xiaomi": ["XIAOMI_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/zai/openclaw.plugin.json b/extensions/zai/openclaw.plugin.json index 5e23160ddb6..c5985d748b0 100644 --- a/extensions/zai/openclaw.plugin.json +++ b/extensions/zai/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "zai", "providers": ["zai"], + "providerAuthEnvVars": { + "zai": ["ZAI_API_KEY", "Z_AI_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/src/agents/model-auth-env-vars.ts b/src/agents/model-auth-env-vars.ts index c9cb9159138..e318cd2e9c8 100644 --- a/src/agents/model-auth-env-vars.ts +++ b/src/agents/model-auth-env-vars.ts @@ -1,45 +1,10 @@ -export const PROVIDER_ENV_API_KEY_CANDIDATES: Record = { - "github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"], - anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], - chutes: ["CHUTES_OAUTH_TOKEN", "CHUTES_API_KEY"], - zai: ["ZAI_API_KEY", "Z_AI_API_KEY"], - opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], - "opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], - "qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], - volcengine: ["VOLCANO_ENGINE_API_KEY"], - "volcengine-plan": ["VOLCANO_ENGINE_API_KEY"], - byteplus: ["BYTEPLUS_API_KEY"], - "byteplus-plan": ["BYTEPLUS_API_KEY"], - "minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"], - "kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"], - huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"], - openai: ["OPENAI_API_KEY"], - google: ["GEMINI_API_KEY"], - voyage: ["VOYAGE_API_KEY"], - groq: ["GROQ_API_KEY"], - deepgram: ["DEEPGRAM_API_KEY"], - cerebras: ["CEREBRAS_API_KEY"], - xai: ["XAI_API_KEY"], - openrouter: ["OPENROUTER_API_KEY"], - litellm: ["LITELLM_API_KEY"], - "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"], - "cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"], - moonshot: ["MOONSHOT_API_KEY"], - minimax: ["MINIMAX_API_KEY"], - nvidia: ["NVIDIA_API_KEY"], - xiaomi: ["XIAOMI_API_KEY"], - synthetic: ["SYNTHETIC_API_KEY"], - venice: ["VENICE_API_KEY"], - mistral: ["MISTRAL_API_KEY"], - together: ["TOGETHER_API_KEY"], - qianfan: ["QIANFAN_API_KEY"], - modelstudio: ["MODELSTUDIO_API_KEY"], - ollama: ["OLLAMA_API_KEY"], - sglang: ["SGLANG_API_KEY"], - vllm: ["VLLM_API_KEY"], - kilocode: ["KILOCODE_API_KEY"], -}; +import { + PROVIDER_AUTH_ENV_VAR_CANDIDATES, + listKnownProviderAuthEnvVarNames, +} from "../secrets/provider-env-vars.js"; + +export const PROVIDER_ENV_API_KEY_CANDIDATES = PROVIDER_AUTH_ENV_VAR_CANDIDATES; export function listKnownProviderEnvApiKeyNames(): string[] { - return [...new Set(Object.values(PROVIDER_ENV_API_KEY_CANDIDATES).flat())]; + return listKnownProviderAuthEnvVarNames(); } diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index a1fc511aaf8..ca509f632d4 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -426,4 +426,45 @@ describe("getApiKeyForModel", () => { }, ); }); + + it("resolveEnvApiKey('qwen-portal') accepts QWEN_OAUTH_TOKEN", async () => { + await withEnvAsync( + { + QWEN_OAUTH_TOKEN: "qwen-oauth-token", + QWEN_PORTAL_API_KEY: undefined, + }, + async () => { + const resolved = resolveEnvApiKey("qwen"); + expect(resolved?.apiKey).toBe("qwen-oauth-token"); + expect(resolved?.source).toContain("QWEN_OAUTH_TOKEN"); + }, + ); + }); + + it("resolveEnvApiKey('minimax-portal') accepts MINIMAX_OAUTH_TOKEN", async () => { + await withEnvAsync( + { + MINIMAX_OAUTH_TOKEN: "minimax-oauth-token", + MINIMAX_API_KEY: undefined, + }, + async () => { + const resolved = resolveEnvApiKey("minimax-portal"); + expect(resolved?.apiKey).toBe("minimax-oauth-token"); + expect(resolved?.source).toContain("MINIMAX_OAUTH_TOKEN"); + }, + ); + }); + + it("resolveEnvApiKey('volcengine-plan') uses volcengine auth candidates", async () => { + await withEnvAsync( + { + VOLCANO_ENGINE_API_KEY: "volcengine-plan-key", + }, + async () => { + const resolved = resolveEnvApiKey("volcengine-plan"); + expect(resolved?.apiKey).toBe("volcengine-plan-key"); + expect(resolved?.source).toContain("VOLCANO_ENGINE_API_KEY"); + }, + ); + }); }); diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 0616bc41194..4a896d5b56b 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -25,7 +25,7 @@ import { isNonSecretApiKeyMarker, OLLAMA_LOCAL_AUTH_MARKER, } from "./model-auth-markers.js"; -import { normalizeProviderId } from "./model-selection.js"; +import { normalizeProviderId, normalizeProviderIdForAuth } from "./model-selection.js"; export { ensureAuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js"; @@ -400,7 +400,7 @@ export function resolveEnvApiKey( provider: string, env: NodeJS.ProcessEnv = process.env, ): EnvApiKeyResult | null { - const normalized = normalizeProviderId(provider); + const normalized = normalizeProviderIdForAuth(provider); const applied = new Set(getShellEnvAppliedKeys()); const pick = (envVar: string): EnvApiKeyResult | null => { const value = normalizeOptionalSecretInput(env[envVar]); diff --git a/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts b/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts index ed4b0a7100c..efcba001638 100644 --- a/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts +++ b/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { DEFAULT_COPILOT_API_BASE_URL } from "../providers/github-copilot-token.js"; +import { DEFAULT_COPILOT_API_BASE_URL } from "../../extensions/github-copilot/token.js"; import { withEnvAsync } from "../test-utils/env.js"; import { installModelsConfigTestHooks, diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts index f9f9934f453..cbea9e5f21b 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts @@ -33,7 +33,7 @@ vi.mock("../infra/backoff.js", () => ({ sleepWithAbort: (ms: number, abortSignal?: AbortSignal) => sleepWithAbortMock(ms, abortSignal), })); -vi.mock("../providers/github-copilot-token.js", () => ({ +vi.mock("../../extensions/github-copilot/token.js", () => ({ DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com", resolveCopilotApiToken: (...args: unknown[]) => resolveCopilotApiTokenMock(...args), })); diff --git a/src/channels/plugins/onboarding-types.ts b/src/channels/plugins/onboarding-types.ts index f560b27b172..8562e6b06a6 100644 --- a/src/channels/plugins/onboarding-types.ts +++ b/src/channels/plugins/onboarding-types.ts @@ -4,13 +4,18 @@ import type { RuntimeEnv } from "../../runtime.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; import type { ChannelId, ChannelPlugin } from "./types.js"; +export type ChannelOnboardingSetupPlugin = Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "setup" | "setupWizard" +>; + export type SetupChannelsOptions = { allowDisable?: boolean; allowSignalInstall?: boolean; onSelection?: (selection: ChannelId[]) => void; accountIds?: Partial>; onAccountId?: (channel: ChannelId, accountId: string) => void; - onResolvedPlugin?: (channel: ChannelId, plugin: ChannelPlugin) => void; + onResolvedPlugin?: (channel: ChannelId, plugin: ChannelOnboardingSetupPlugin) => void; promptAccountIds?: boolean; whatsappAccountId?: string; promptWhatsAppAccountId?: boolean; diff --git a/src/commands/channel-setup/registry.ts b/src/commands/channel-setup/registry.ts index 576d7e14b60..bedc2f9bf6d 100644 --- a/src/commands/channel-setup/registry.ts +++ b/src/commands/channel-setup/registry.ts @@ -25,10 +25,15 @@ const EMPTY_REGISTRY_FALLBACK_PLUGINS = [ linePlugin, ]; +export type ChannelOnboardingSetupPlugin = Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" | "setup" | "setupWizard" +>; + const setupWizardAdapters = new WeakMap(); export function resolveChannelOnboardingAdapterForPlugin( - plugin?: ChannelPlugin, + plugin?: ChannelOnboardingSetupPlugin, ): ChannelOnboardingAdapter | undefined { if (plugin?.setupWizard) { const cached = setupWizardAdapters.get(plugin); @@ -74,7 +79,7 @@ export function listChannelOnboardingAdapters(): ChannelOnboardingAdapter[] { export async function loadBundledChannelOnboardingPlugin( channel: ChannelChoice, -): Promise { +): Promise { switch (channel) { case "discord": return discordPlugin as ChannelPlugin; diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 0c9b5b15e56..30fe44f1b54 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -2,6 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/ag import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js"; import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; +import type { ChannelOnboardingSetupPlugin } from "../../channels/plugins/onboarding-types.js"; import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js"; import type { ChannelId, ChannelPlugin, ChannelSetupInput } from "../../channels/plugins/types.js"; import { writeConfigFile, type OpenClawConfig } from "../../config/config.js"; @@ -56,7 +57,7 @@ export async function channelsAddCommand( const prompter = createClackPrompter(); let selection: ChannelChoice[] = []; const accountIds: Partial> = {}; - const resolvedPlugins = new Map(); + const resolvedPlugins = new Map(); await prompter.intro("Channel setup"); let nextConfig = await setupChannels(cfg, runtime, prompter, { allowDisable: false, diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 103f81cbff9..ffc4932f7b8 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -1,11 +1,11 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; +import type { ChannelOnboardingSetupPlugin } from "../channels/plugins/onboarding-types.js"; import { getChannelSetupPlugin, listChannelSetupPlugins, } from "../channels/plugins/setup-registry.js"; -import type { ChannelPlugin } from "../channels/plugins/types.js"; import { formatChannelPrimerLine, formatChannelSelectionLine, @@ -94,7 +94,7 @@ async function promptRemovalAccountId(params: { prompter: WizardPrompter; label: string; channel: ChannelChoice; - plugin?: ChannelPlugin; + plugin?: ChannelOnboardingSetupPlugin; }): Promise { const { cfg, prompter, label, channel } = params; const plugin = params.plugin ?? getChannelSetupPlugin(channel); @@ -121,7 +121,7 @@ async function collectChannelStatus(params: { cfg: OpenClawConfig; options?: SetupChannelsOptions; accountOverrides: Partial>; - installedPlugins?: ChannelPlugin[]; + installedPlugins?: ChannelOnboardingSetupPlugin[]; resolveAdapter?: (channel: ChannelChoice) => ChannelOnboardingAdapter | undefined; }): Promise { const installedPlugins = params.installedPlugins ?? listChannelSetupPlugins(); @@ -279,7 +279,7 @@ async function maybeConfigureDmPolicies(params: { const { selection, prompter, accountIdsByChannel } = params; const resolve = params.resolveAdapter ?? (() => undefined); const dmPolicies = selection - .map((channel) => resolve(channel)?.dmPolicy) + .map((channel) => resolve?.(channel)?.dmPolicy) .filter(Boolean) as ChannelOnboardingDmPolicy[]; if (dmPolicies.length === 0) { return params.cfg; @@ -350,17 +350,19 @@ export async function setupChannels( const accountOverrides: Partial> = { ...options?.accountIds, }; - const scopedPluginsById = new Map(); + const scopedPluginsById = new Map(); const resolveWorkspaceDir = () => resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next)); - const rememberScopedPlugin = (plugin: ChannelPlugin) => { + const rememberScopedPlugin = (plugin: ChannelOnboardingSetupPlugin) => { const channel = plugin.id; scopedPluginsById.set(channel, plugin); options?.onResolvedPlugin?.(channel, plugin); }; - const getVisibleChannelPlugin = (channel: ChannelChoice): ChannelPlugin | undefined => + const getVisibleChannelPlugin = ( + channel: ChannelChoice, + ): ChannelOnboardingSetupPlugin | undefined => scopedPluginsById.get(channel) ?? getChannelSetupPlugin(channel); - const listVisibleInstalledPlugins = (): ChannelPlugin[] => { - const merged = new Map(); + const listVisibleInstalledPlugins = (): ChannelOnboardingSetupPlugin[] => { + const merged = new Map(); for (const plugin of listChannelSetupPlugins()) { merged.set(plugin.id, plugin); } @@ -372,7 +374,7 @@ export async function setupChannels( const loadScopedChannelPlugin = async ( channel: ChannelChoice, pluginId?: string, - ): Promise => { + ): Promise => { const existing = getVisibleChannelPlugin(channel); if (existing) { return existing; diff --git a/src/daemon/program-args.test.ts b/src/daemon/program-args.test.ts index 68dc4edb71c..920f4533297 100644 --- a/src/daemon/program-args.test.ts +++ b/src/daemon/program-args.test.ts @@ -1,6 +1,10 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +const childProcessMocks = vi.hoisted(() => ({ + execFileSync: vi.fn(), +})); + const fsMocks = vi.hoisted(() => ({ access: vi.fn(), realpath: vi.fn(), @@ -12,6 +16,10 @@ vi.mock("node:fs/promises", () => ({ realpath: fsMocks.realpath, })); +vi.mock("node:child_process", () => ({ + execFileSync: childProcessMocks.execFileSync, +})); + import { resolveGatewayProgramArguments } from "./program-args.js"; const originalArgv = [...process.argv]; @@ -87,4 +95,28 @@ describe("resolveGatewayProgramArguments", () => { "18789", ]); }); + + it("uses src/entry.ts for bun dev mode", async () => { + const repoIndexPath = path.resolve("/repo/src/index.ts"); + const repoEntryPath = path.resolve("/repo/src/entry.ts"); + process.argv = ["/usr/local/bin/node", repoIndexPath]; + fsMocks.realpath.mockResolvedValue(repoIndexPath); + fsMocks.access.mockResolvedValue(undefined); + childProcessMocks.execFileSync.mockReturnValue("/usr/local/bin/bun\n"); + + const result = await resolveGatewayProgramArguments({ + dev: true, + port: 18789, + runtime: "bun", + }); + + expect(result.programArguments).toEqual([ + "/usr/local/bin/bun", + repoEntryPath, + "gateway", + "--port", + "18789", + ]); + expect(result.workingDirectory).toBe(path.resolve("/repo")); + }); }); diff --git a/src/daemon/program-args.ts b/src/daemon/program-args.ts index 76bad8fc1ce..9e60f26f761 100644 --- a/src/daemon/program-args.ts +++ b/src/daemon/program-args.ts @@ -123,7 +123,7 @@ function resolveRepoRootForDev(): string { const parts = normalized.split(path.sep); const srcIndex = parts.lastIndexOf("src"); if (srcIndex === -1) { - throw new Error("Dev mode requires running from repo (src/index.ts)"); + throw new Error("Dev mode requires running from repo (src/entry.ts)"); } return parts.slice(0, srcIndex).join(path.sep); } @@ -180,7 +180,7 @@ async function resolveCliProgramArguments(params: { if (runtime === "bun") { if (params.dev) { const repoRoot = resolveRepoRootForDev(); - const devCliPath = path.join(repoRoot, "src", "index.ts"); + const devCliPath = path.join(repoRoot, "src", "entry.ts"); await fs.access(devCliPath); const bunPath = isBunRuntime(execPath) ? execPath : await resolveBunPath(); return { @@ -213,7 +213,7 @@ async function resolveCliProgramArguments(params: { // Dev mode: use bun to run TypeScript directly const repoRoot = resolveRepoRootForDev(); - const devCliPath = path.join(repoRoot, "src", "index.ts"); + const devCliPath = path.join(repoRoot, "src", "entry.ts"); await fs.access(devCliPath); // If already running under bun, use current execPath diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 00000000000..d53d492c527 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,46 @@ +import fs from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const runtimeMocks = vi.hoisted(() => ({ + runCli: vi.fn(async () => {}), +})); + +vi.mock("./cli/run-main.js", () => ({ + runCli: runtimeMocks.runCli, +})); + +describe("legacy root entry", () => { + afterEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it("routes the package root export to the pure library entry", () => { + const packageJson = JSON.parse( + fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ) as { + exports?: Record; + main?: string; + }; + + expect(packageJson.main).toBe("dist/index.js"); + expect(packageJson.exports?.["."]).toBe("./dist/index.js"); + }); + + it("does not run CLI bootstrap when imported as a library dependency", async () => { + const mod = await import("./index.js"); + + expect(typeof mod.runLegacyCliEntry).toBe("function"); + expect(runtimeMocks.runCli).not.toHaveBeenCalled(); + }); + + it("delegates legacy direct-entry execution to run-main", async () => { + const mod = await import("./index.js"); + const argv = ["node", "dist/index.js", "status"]; + + await mod.runLegacyCliEntry(argv); + + expect(runtimeMocks.runCli).toHaveBeenCalledOnce(); + expect(runtimeMocks.runCli).toHaveBeenCalledWith(argv); + }); +}); diff --git a/src/index.ts b/src/index.ts index 61d96ccee33..4daf6521df7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,76 +1,40 @@ #!/usr/bin/env node import process from "node:process"; import { fileURLToPath } from "node:url"; -import { getReplyFromConfig } from "./auto-reply/reply.js"; -import { applyTemplate } from "./auto-reply/templating.js"; -import { monitorWebChannel } from "./channel-web.js"; -import { createDefaultDeps } from "./cli/deps.js"; -import { promptYesNo } from "./cli/prompt.js"; -import { waitForever } from "./cli/wait.js"; -import { loadConfig } from "./config/config.js"; -import { - deriveSessionKey, - loadSessionStore, - resolveSessionKey, - resolveStorePath, - saveSessionStore, -} from "./config/sessions.js"; -import { ensureBinary } from "./infra/binaries.js"; -import { loadDotEnv } from "./infra/dotenv.js"; -import { normalizeEnv } from "./infra/env.js"; import { formatUncaughtError } from "./infra/errors.js"; import { isMainModule } from "./infra/is-main.js"; -import { ensureOpenClawCliOnPath } from "./infra/path-env.js"; -import { - describePortOwner, - ensurePortAvailable, - handlePortError, - PortInUseError, -} from "./infra/ports.js"; -import { assertSupportedRuntime } from "./infra/runtime-guard.js"; import { installUnhandledRejectionHandler } from "./infra/unhandled-rejections.js"; -import { enableConsoleCapture } from "./logging.js"; -import { runCommandWithTimeout, runExec } from "./process/exec.js"; -import { assertWebChannel, normalizeE164, toWhatsappJid } from "./utils.js"; -loadDotEnv({ quiet: true }); -normalizeEnv(); -ensureOpenClawCliOnPath(); +const library = await import("./library.js"); -// Capture all console output into structured logs while keeping stdout/stderr behavior. -enableConsoleCapture(); +export const assertWebChannel = library.assertWebChannel; +export const applyTemplate = library.applyTemplate; +export const createDefaultDeps = library.createDefaultDeps; +export const deriveSessionKey = library.deriveSessionKey; +export const describePortOwner = library.describePortOwner; +export const ensureBinary = library.ensureBinary; +export const ensurePortAvailable = library.ensurePortAvailable; +export const getReplyFromConfig = library.getReplyFromConfig; +export const handlePortError = library.handlePortError; +export const loadConfig = library.loadConfig; +export const loadSessionStore = library.loadSessionStore; +export const monitorWebChannel = library.monitorWebChannel; +export const normalizeE164 = library.normalizeE164; +export const PortInUseError = library.PortInUseError; +export const promptYesNo = library.promptYesNo; +export const resolveSessionKey = library.resolveSessionKey; +export const resolveStorePath = library.resolveStorePath; +export const runCommandWithTimeout = library.runCommandWithTimeout; +export const runExec = library.runExec; +export const saveSessionStore = library.saveSessionStore; +export const toWhatsappJid = library.toWhatsappJid; +export const waitForever = library.waitForever; -// Enforce the minimum supported runtime before doing any work. -assertSupportedRuntime(); - -import { buildProgram } from "./cli/program.js"; - -const program = buildProgram(); - -export { - assertWebChannel, - applyTemplate, - createDefaultDeps, - deriveSessionKey, - describePortOwner, - ensureBinary, - ensurePortAvailable, - getReplyFromConfig, - handlePortError, - loadConfig, - loadSessionStore, - monitorWebChannel, - normalizeE164, - PortInUseError, - promptYesNo, - resolveSessionKey, - resolveStorePath, - runCommandWithTimeout, - runExec, - saveSessionStore, - toWhatsappJid, - waitForever, -}; +// Legacy direct file entrypoint only. Package root exports now live in library.ts. +export async function runLegacyCliEntry(argv: string[] = process.argv): Promise { + const { runCli } = await import("./cli/run-main.js"); + await runCli(argv); +} const isMain = isMainModule({ currentFile: fileURLToPath(import.meta.url), @@ -86,7 +50,7 @@ if (isMain) { process.exit(1); }); - void program.parseAsync(process.argv).catch((err) => { + void runLegacyCliEntry(process.argv).catch((err) => { console.error("[openclaw] CLI failed:", formatUncaughtError(err)); process.exit(1); }); diff --git a/src/infra/gateway-process-argv.test.ts b/src/infra/gateway-process-argv.test.ts index 81e6da2210a..8f072a80ca6 100644 --- a/src/infra/gateway-process-argv.test.ts +++ b/src/infra/gateway-process-argv.test.ts @@ -26,6 +26,7 @@ describe("isGatewayArgv", () => { expect(isGatewayArgv(["NODE", "C:\\OpenClaw\\DIST\\ENTRY.JS", "gateway"])).toBe(true); expect(isGatewayArgv(["bun", "/srv/openclaw/scripts/run-node.mjs", "gateway"])).toBe(true); expect(isGatewayArgv(["node", "/srv/openclaw/openclaw.mjs", "gateway"])).toBe(true); + expect(isGatewayArgv(["tsx", "/srv/openclaw/src/entry.ts", "gateway"])).toBe(true); expect(isGatewayArgv(["tsx", "/srv/openclaw/src/index.ts", "gateway"])).toBe(true); }); diff --git a/src/infra/gateway-process-argv.ts b/src/infra/gateway-process-argv.ts index 59f042ead88..47eab54fce2 100644 --- a/src/infra/gateway-process-argv.ts +++ b/src/infra/gateway-process-argv.ts @@ -20,6 +20,7 @@ export function isGatewayArgv(args: string[], opts?: { allowGatewayBinary?: bool "dist/entry.js", "openclaw.mjs", "scripts/run-node.mjs", + "src/entry.ts", "src/index.ts", ]; if (normalized.some((arg) => entryCandidates.some((entry) => arg.endsWith(entry)))) { diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index a3981fe5f32..00bba63f2e1 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -1,6 +1,3 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { dedupeProfileIds, ensureAuthProfileStore, @@ -14,7 +11,6 @@ import { normalizeProviderId } from "../agents/model-selection.js"; import { loadConfig, type OpenClawConfig } from "../config/config.js"; import { resolveProviderUsageAuthWithPlugin } from "../plugins/provider-runtime.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; -import { resolveRequiredHomeDir } from "./home-dir.js"; import type { UsageProviderId } from "./provider-usage.types.js"; export type ProviderAuth = { @@ -32,46 +28,6 @@ type UsageAuthState = { agentDir?: string; }; -const LEGACY_OAUTH_USAGE_PROVIDERS = new Set([ - "anthropic", - "github-copilot", - "google-gemini-cli", - "openai-codex", -]); - -function parseGoogleToken(apiKey: string): { token: string } | null { - try { - const parsed = JSON.parse(apiKey) as { token?: unknown }; - if (parsed && typeof parsed.token === "string") { - return { token: parsed.token }; - } - } catch { - // ignore - } - return null; -} - -function resolveLegacyZaiApiKey(state: UsageAuthState): string | undefined { - try { - const authPath = path.join( - resolveRequiredHomeDir(state.env, os.homedir), - ".pi", - "agent", - "auth.json", - ); - if (!fs.existsSync(authPath)) { - return undefined; - } - const data = JSON.parse(fs.readFileSync(authPath, "utf-8")) as Record< - string, - { access?: string } - >; - return data["z-ai"]?.access || data.zai?.access; - } catch { - return undefined; - } -} - function resolveProviderApiKeyFromConfigAndStore(params: { state: UsageAuthState; providerIds: string[]; @@ -236,66 +192,7 @@ export async function resolveProviderAuths(params: { }); if (pluginAuth) { auths.push(pluginAuth); - continue; } - - if (provider === "zai") { - const apiKey = - resolveProviderApiKeyFromConfigAndStore({ - state, - providerIds: ["zai", "z-ai"], - envDirect: [state.env.ZAI_API_KEY, state.env.Z_AI_API_KEY], - }) ?? resolveLegacyZaiApiKey(state); - if (apiKey) { - auths.push({ provider, token: apiKey }); - } - continue; - } - - if (provider === "minimax") { - const apiKey = resolveProviderApiKeyFromConfigAndStore({ - state, - providerIds: ["minimax"], - envDirect: [state.env.MINIMAX_CODE_PLAN_KEY, state.env.MINIMAX_API_KEY], - }); - if (apiKey) { - auths.push({ provider, token: apiKey }); - } - continue; - } - - if (provider === "xiaomi") { - const apiKey = resolveProviderApiKeyFromConfigAndStore({ - state, - providerIds: ["xiaomi"], - envDirect: [state.env.XIAOMI_API_KEY], - }); - if (apiKey) { - auths.push({ provider, token: apiKey }); - } - continue; - } - - if (!LEGACY_OAUTH_USAGE_PROVIDERS.has(provider)) { - continue; - } - - const auth = await resolveOAuthToken({ - state, - provider, - }); - if (!auth) { - continue; - } - if (provider === "google-gemini-cli") { - const parsed = parseGoogleToken(auth.token); - auths.push({ - ...auth, - token: parsed?.token ?? auth.token, - }); - continue; - } - auths.push(auth); } return auths; diff --git a/src/infra/provider-usage.fetch.ts b/src/infra/provider-usage.fetch.ts index e0bcd60c94b..87f216eef24 100644 --- a/src/infra/provider-usage.fetch.ts +++ b/src/infra/provider-usage.fetch.ts @@ -1,6 +1,5 @@ export { fetchClaudeUsage } from "./provider-usage.fetch.claude.js"; export { fetchCodexUsage } from "./provider-usage.fetch.codex.js"; -export { fetchCopilotUsage } from "./provider-usage.fetch.copilot.js"; export { fetchGeminiUsage } from "./provider-usage.fetch.gemini.js"; export { fetchMinimaxUsage } from "./provider-usage.fetch.minimax.js"; export { fetchZaiUsage } from "./provider-usage.fetch.zai.js"; diff --git a/src/infra/provider-usage.load.plugin.test.ts b/src/infra/provider-usage.load.plugin.test.ts index cf78ac667da..55cff6cad72 100644 --- a/src/infra/provider-usage.load.plugin.test.ts +++ b/src/infra/provider-usage.load.plugin.test.ts @@ -22,7 +22,7 @@ describe("provider-usage.load plugin seam", () => { resolveProviderUsageSnapshotWithPluginMock.mockResolvedValue(null); }); - it("prefers plugin-owned usage snapshots before the legacy core switch", async () => { + it("prefers plugin-owned usage snapshots", async () => { resolveProviderUsageSnapshotWithPluginMock.mockResolvedValueOnce({ provider: "github-copilot", displayName: "Copilot", diff --git a/src/infra/provider-usage.load.ts b/src/infra/provider-usage.load.ts index 9b50285c64f..d34c55c22d3 100644 --- a/src/infra/provider-usage.load.ts +++ b/src/infra/provider-usage.load.ts @@ -2,14 +2,6 @@ import { loadConfig, type OpenClawConfig } from "../config/config.js"; import { resolveProviderUsageSnapshotWithPlugin } from "../plugins/provider-runtime.js"; import { resolveFetch } from "./fetch.js"; import { type ProviderAuth, resolveProviderAuths } from "./provider-usage.auth.js"; -import { - fetchClaudeUsage, - fetchCodexUsage, - fetchCopilotUsage, - fetchGeminiUsage, - fetchMinimaxUsage, - fetchZaiUsage, -} from "./provider-usage.fetch.js"; import { DEFAULT_TIMEOUT_MS, ignoredErrors, @@ -64,44 +56,12 @@ async function fetchProviderUsageSnapshot(params: { if (pluginSnapshot) { return pluginSnapshot; } - - switch (params.auth.provider) { - case "anthropic": - return await fetchClaudeUsage(params.auth.token, params.timeoutMs, params.fetchFn); - case "github-copilot": - return await fetchCopilotUsage(params.auth.token, params.timeoutMs, params.fetchFn); - case "google-gemini-cli": - return await fetchGeminiUsage( - params.auth.token, - params.timeoutMs, - params.fetchFn, - params.auth.provider, - ); - case "openai-codex": - return await fetchCodexUsage( - params.auth.token, - params.auth.accountId, - params.timeoutMs, - params.fetchFn, - ); - case "minimax": - return await fetchMinimaxUsage(params.auth.token, params.timeoutMs, params.fetchFn); - case "xiaomi": - return { - provider: "xiaomi", - displayName: PROVIDER_LABELS.xiaomi, - windows: [], - }; - case "zai": - return await fetchZaiUsage(params.auth.token, params.timeoutMs, params.fetchFn); - default: - return { - provider: params.auth.provider, - displayName: PROVIDER_LABELS[params.auth.provider], - windows: [], - error: "Unsupported provider", - }; - } + return { + provider: params.auth.provider, + displayName: PROVIDER_LABELS[params.auth.provider], + windows: [], + error: "Unsupported provider", + }; } export async function loadProviderUsageSummary( diff --git a/src/library.ts b/src/library.ts new file mode 100644 index 00000000000..faaf7ea5998 --- /dev/null +++ b/src/library.ts @@ -0,0 +1,48 @@ +import { getReplyFromConfig } from "./auto-reply/reply.js"; +import { applyTemplate } from "./auto-reply/templating.js"; +import { monitorWebChannel } from "./channel-web.js"; +import { createDefaultDeps } from "./cli/deps.js"; +import { promptYesNo } from "./cli/prompt.js"; +import { waitForever } from "./cli/wait.js"; +import { loadConfig } from "./config/config.js"; +import { + deriveSessionKey, + loadSessionStore, + resolveSessionKey, + resolveStorePath, + saveSessionStore, +} from "./config/sessions.js"; +import { ensureBinary } from "./infra/binaries.js"; +import { + describePortOwner, + ensurePortAvailable, + handlePortError, + PortInUseError, +} from "./infra/ports.js"; +import { runCommandWithTimeout, runExec } from "./process/exec.js"; +import { assertWebChannel, normalizeE164, toWhatsappJid } from "./utils.js"; + +export { + assertWebChannel, + applyTemplate, + createDefaultDeps, + deriveSessionKey, + describePortOwner, + ensureBinary, + ensurePortAvailable, + getReplyFromConfig, + handlePortError, + loadConfig, + loadSessionStore, + monitorWebChannel, + normalizeE164, + PortInUseError, + promptYesNo, + resolveSessionKey, + resolveStorePath, + runCommandWithTimeout, + runExec, + saveSessionStore, + toWhatsappJid, + waitForever, +}; diff --git a/src/plugins/bundled-provider-auth-env-vars.test.ts b/src/plugins/bundled-provider-auth-env-vars.test.ts new file mode 100644 index 00000000000..81523392e7a --- /dev/null +++ b/src/plugins/bundled-provider-auth-env-vars.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES } from "./bundled-provider-auth-env-vars.js"; + +describe("bundled provider auth env vars", () => { + it("reads bundled provider auth env vars from plugin manifests", () => { + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["github-copilot"]).toEqual([ + "COPILOT_GITHUB_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", + ]); + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["qwen-portal"]).toEqual([ + "QWEN_OAUTH_TOKEN", + "QWEN_PORTAL_API_KEY", + ]); + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["minimax-portal"]).toEqual([ + "MINIMAX_OAUTH_TOKEN", + "MINIMAX_API_KEY", + ]); + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES.openai).toEqual(["OPENAI_API_KEY"]); + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["openai-codex"]).toBeUndefined(); + }); +}); diff --git a/src/plugins/bundled-provider-auth-env-vars.ts b/src/plugins/bundled-provider-auth-env-vars.ts new file mode 100644 index 00000000000..5c152de0566 --- /dev/null +++ b/src/plugins/bundled-provider-auth-env-vars.ts @@ -0,0 +1,91 @@ +import ANTHROPIC_MANIFEST from "../../extensions/anthropic/openclaw.plugin.json" with { type: "json" }; +import BYTEPLUS_MANIFEST from "../../extensions/byteplus/openclaw.plugin.json" with { type: "json" }; +import CLOUDFLARE_AI_GATEWAY_MANIFEST from "../../extensions/cloudflare-ai-gateway/openclaw.plugin.json" with { type: "json" }; +import COPILOT_PROXY_MANIFEST from "../../extensions/copilot-proxy/openclaw.plugin.json" with { type: "json" }; +import GITHUB_COPILOT_MANIFEST from "../../extensions/github-copilot/openclaw.plugin.json" with { type: "json" }; +import GOOGLE_MANIFEST from "../../extensions/google/openclaw.plugin.json" with { type: "json" }; +import HUGGINGFACE_MANIFEST from "../../extensions/huggingface/openclaw.plugin.json" with { type: "json" }; +import KILOCODE_MANIFEST from "../../extensions/kilocode/openclaw.plugin.json" with { type: "json" }; +import KIMI_CODING_MANIFEST from "../../extensions/kimi-coding/openclaw.plugin.json" with { type: "json" }; +import MINIMAX_MANIFEST from "../../extensions/minimax/openclaw.plugin.json" with { type: "json" }; +import MISTRAL_MANIFEST from "../../extensions/mistral/openclaw.plugin.json" with { type: "json" }; +import MODELSTUDIO_MANIFEST from "../../extensions/modelstudio/openclaw.plugin.json" with { type: "json" }; +import MOONSHOT_MANIFEST from "../../extensions/moonshot/openclaw.plugin.json" with { type: "json" }; +import NVIDIA_MANIFEST from "../../extensions/nvidia/openclaw.plugin.json" with { type: "json" }; +import OLLAMA_MANIFEST from "../../extensions/ollama/openclaw.plugin.json" with { type: "json" }; +import OPENAI_MANIFEST from "../../extensions/openai/openclaw.plugin.json" with { type: "json" }; +import OPENCODE_GO_MANIFEST from "../../extensions/opencode-go/openclaw.plugin.json" with { type: "json" }; +import OPENCODE_MANIFEST from "../../extensions/opencode/openclaw.plugin.json" with { type: "json" }; +import OPENROUTER_MANIFEST from "../../extensions/openrouter/openclaw.plugin.json" with { type: "json" }; +import QIANFAN_MANIFEST from "../../extensions/qianfan/openclaw.plugin.json" with { type: "json" }; +import QWEN_PORTAL_AUTH_MANIFEST from "../../extensions/qwen-portal-auth/openclaw.plugin.json" with { type: "json" }; +import SGLANG_MANIFEST from "../../extensions/sglang/openclaw.plugin.json" with { type: "json" }; +import SYNTHETIC_MANIFEST from "../../extensions/synthetic/openclaw.plugin.json" with { type: "json" }; +import TOGETHER_MANIFEST from "../../extensions/together/openclaw.plugin.json" with { type: "json" }; +import VENICE_MANIFEST from "../../extensions/venice/openclaw.plugin.json" with { type: "json" }; +import VERCEL_AI_GATEWAY_MANIFEST from "../../extensions/vercel-ai-gateway/openclaw.plugin.json" with { type: "json" }; +import VLLM_MANIFEST from "../../extensions/vllm/openclaw.plugin.json" with { type: "json" }; +import VOLCENGINE_MANIFEST from "../../extensions/volcengine/openclaw.plugin.json" with { type: "json" }; +import XIAOMI_MANIFEST from "../../extensions/xiaomi/openclaw.plugin.json" with { type: "json" }; +import ZAI_MANIFEST from "../../extensions/zai/openclaw.plugin.json" with { type: "json" }; + +type ProviderAuthEnvVarManifest = { + id?: string; + providerAuthEnvVars?: Record; +}; + +function collectBundledProviderAuthEnvVars( + manifests: readonly ProviderAuthEnvVarManifest[], +): Record { + const entries: Record = {}; + for (const manifest of manifests) { + const providerAuthEnvVars = manifest.providerAuthEnvVars; + if (!providerAuthEnvVars) { + continue; + } + for (const [providerId, envVars] of Object.entries(providerAuthEnvVars)) { + const normalizedProviderId = providerId.trim(); + const normalizedEnvVars = envVars.map((value) => value.trim()).filter(Boolean); + if (!normalizedProviderId || normalizedEnvVars.length === 0) { + continue; + } + entries[normalizedProviderId] = normalizedEnvVars; + } + } + return entries; +} + +// Read bundled provider auth env metadata from manifests so env-based auth +// lookup stays cheap and does not need to boot plugin runtime code. +export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = collectBundledProviderAuthEnvVars([ + ANTHROPIC_MANIFEST, + BYTEPLUS_MANIFEST, + CLOUDFLARE_AI_GATEWAY_MANIFEST, + COPILOT_PROXY_MANIFEST, + GITHUB_COPILOT_MANIFEST, + GOOGLE_MANIFEST, + HUGGINGFACE_MANIFEST, + KILOCODE_MANIFEST, + KIMI_CODING_MANIFEST, + MINIMAX_MANIFEST, + MISTRAL_MANIFEST, + MODELSTUDIO_MANIFEST, + MOONSHOT_MANIFEST, + NVIDIA_MANIFEST, + OLLAMA_MANIFEST, + OPENAI_MANIFEST, + OPENCODE_GO_MANIFEST, + OPENCODE_MANIFEST, + OPENROUTER_MANIFEST, + QIANFAN_MANIFEST, + QWEN_PORTAL_AUTH_MANIFEST, + SGLANG_MANIFEST, + SYNTHETIC_MANIFEST, + TOGETHER_MANIFEST, + VENICE_MANIFEST, + VERCEL_AI_GATEWAY_MANIFEST, + VLLM_MANIFEST, + VOLCENGINE_MANIFEST, + XIAOMI_MANIFEST, + ZAI_MANIFEST, +]); diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index c4195a5e6e3..8becf375f96 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -213,4 +213,9 @@ describe("resolveEnableState", () => { reason: "workspace plugin (disabled by default)", }); }); + + it("keeps bundled provider plugins enabled when they are bundled-default providers", () => { + const state = resolveEnableState("google", "bundled", normalizePluginsConfig({})); + expect(state).toEqual({ enabled: true }); + }); }); diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 493ad885f51..6cd04424fe2 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -29,6 +29,7 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ "cloudflare-ai-gateway", "device-pair", "github-copilot", + "google", "huggingface", "kilocode", "kimi-coding", diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index a58d0a640a2..90f9b210398 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -28,7 +28,7 @@ import { isPathInside, safeStatSync } from "./path-safety.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; import { resolvePluginCacheInputs } from "./roots.js"; import { setActivePluginRegistry } from "./runtime.js"; -import { createPluginRuntime, type CreatePluginRuntimeOptions } from "./runtime/index.js"; +import type { CreatePluginRuntimeOptions } from "./runtime/index.js"; import type { PluginRuntime } from "./runtime/types.js"; import { validateJsonSchemaValue } from "./schema-validator.js"; import type { @@ -163,6 +163,25 @@ const resolveExtensionApiAlias = (params: { modulePath?: string } = {}): string return null; }; +function resolvePluginRuntimeModulePath(params: { modulePath?: string } = {}): string | null { + try { + const modulePath = params.modulePath ?? fileURLToPath(import.meta.url); + const moduleDir = path.dirname(modulePath); + const candidates = [ + path.join(moduleDir, "runtime", "index.ts"), + path.join(moduleDir, "runtime", "index.js"), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + } catch { + // ignore + } + return null; +} + const cachedPluginSdkExportedSubpaths = new Map(); function listPluginSdkExportedSubpaths(params: { modulePath?: string } = {}): string[] { @@ -747,11 +766,58 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi clearPluginInteractiveHandlers(); } + // Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests). + let jitiLoader: ReturnType | null = null; + const getJiti = () => { + if (jitiLoader) { + return jitiLoader; + } + const pluginSdkAlias = resolvePluginSdkAlias(); + const extensionApiAlias = resolveExtensionApiAlias(); + const aliasMap = { + ...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}), + ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), + ...resolvePluginSdkScopedAliasMap(), + }; + jitiLoader = createJiti(import.meta.url, { + interopDefault: true, + extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], + ...(Object.keys(aliasMap).length > 0 + ? { + alias: aliasMap, + } + : {}), + }); + return jitiLoader; + }; + + let createPluginRuntimeFactory: ((options?: CreatePluginRuntimeOptions) => PluginRuntime) | null = + null; + const resolveCreatePluginRuntime = (): (( + options?: CreatePluginRuntimeOptions, + ) => PluginRuntime) => { + if (createPluginRuntimeFactory) { + return createPluginRuntimeFactory; + } + const runtimeModulePath = resolvePluginRuntimeModulePath(); + if (!runtimeModulePath) { + throw new Error("Unable to resolve plugin runtime module"); + } + const runtimeModule = getJiti()(runtimeModulePath) as { + createPluginRuntime?: (options?: CreatePluginRuntimeOptions) => PluginRuntime; + }; + if (typeof runtimeModule.createPluginRuntime !== "function") { + throw new Error("Plugin runtime module missing createPluginRuntime export"); + } + createPluginRuntimeFactory = runtimeModule.createPluginRuntime; + return createPluginRuntimeFactory; + }; + // Lazily initialize the runtime so startup paths that discover/skip plugins do - // not eagerly load every channel runtime dependency. + // not eagerly load every channel/runtime dependency tree. let resolvedRuntime: PluginRuntime | null = null; const resolveRuntime = (): PluginRuntime => { - resolvedRuntime ??= createPluginRuntime(options.runtimeOptions); + resolvedRuntime ??= resolveCreatePluginRuntime()(options.runtimeOptions); return resolvedRuntime; }; const runtime = new Proxy({} as PluginRuntime, { @@ -780,6 +846,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi return Reflect.getPrototypeOf(resolveRuntime() as object); }, }); + const { registry, createApi } = createPluginRegistry({ logger, runtime, @@ -823,31 +890,6 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi env, }); - // Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests). - let jitiLoader: ReturnType | null = null; - const getJiti = () => { - if (jitiLoader) { - return jitiLoader; - } - const pluginSdkAlias = resolvePluginSdkAlias(); - const extensionApiAlias = resolveExtensionApiAlias(); - const aliasMap = { - ...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}), - ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), - ...resolvePluginSdkScopedAliasMap(), - }; - jitiLoader = createJiti(import.meta.url, { - interopDefault: true, - extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], - ...(Object.keys(aliasMap).length > 0 - ? { - alias: aliasMap, - } - : {}), - }); - return jitiLoader; - }; - const manifestByRoot = new Map( manifestRegistry.plugins.map((record) => [record.rootDir, record]), ); diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 84e5f13fd98..5156ea8a4a3 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -199,6 +199,28 @@ describe("loadPluginManifestRegistry", () => { ).toBe(true); }); + it("preserves provider auth env metadata from plugin manifests", () => { + const dir = makeTempDir(); + writeManifest(dir, { + id: "openai", + providers: ["openai", "openai-codex"], + providerAuthEnvVars: { + openai: ["OPENAI_API_KEY"], + }, + configSchema: { type: "object" }, + }); + + const registry = loadSingleCandidateRegistry({ + idHint: "openai", + rootDir: dir, + origin: "bundled", + }); + + expect(registry.plugins[0]?.providerAuthEnvVars).toEqual({ + openai: ["OPENAI_API_KEY"], + }); + }); + it("reports bundled plugins as the duplicate winner for auto-discovered globals", () => { const bundledDir = makeTempDir(); const globalDir = makeTempDir(); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 4f43cff8e2b..3a96d3036d5 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -41,6 +41,7 @@ export type PluginManifestRecord = { kind?: PluginKind; channels: string[]; providers: string[]; + providerAuthEnvVars?: Record; skills: string[]; settingsFiles?: string[]; hooks: string[]; @@ -152,6 +153,7 @@ function buildRecord(params: { kind: params.manifest.kind, channels: params.manifest.channels ?? [], providers: params.manifest.providers ?? [], + providerAuthEnvVars: params.manifest.providerAuthEnvVars, skills: params.manifest.skills ?? [], settingsFiles: [], hooks: [], diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 0cbdd9264f3..103ee620bf0 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -14,6 +14,7 @@ export type PluginManifest = { kind?: PluginKind; channels?: string[]; providers?: string[]; + providerAuthEnvVars?: Record; skills?: string[]; name?: string; description?: string; @@ -32,6 +33,25 @@ function normalizeStringList(value: unknown): string[] { return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); } +function normalizeStringListRecord(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const normalized: Record = {}; + for (const [key, rawValues] of Object.entries(value)) { + const providerId = typeof key === "string" ? key.trim() : ""; + if (!providerId) { + continue; + } + const values = normalizeStringList(rawValues); + if (values.length === 0) { + continue; + } + normalized[providerId] = values; + } + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + export function resolvePluginManifestPath(rootDir: string): string { for (const filename of PLUGIN_MANIFEST_FILENAMES) { const candidate = path.join(rootDir, filename); @@ -93,6 +113,7 @@ export function loadPluginManifest( const version = typeof raw.version === "string" ? raw.version.trim() : undefined; const channels = normalizeStringList(raw.channels); const providers = normalizeStringList(raw.providers); + const providerAuthEnvVars = normalizeStringListRecord(raw.providerAuthEnvVars); const skills = normalizeStringList(raw.skills); let uiHints: Record | undefined; @@ -108,6 +129,7 @@ export function loadPluginManifest( kind, channels, providers, + providerAuthEnvVars, skills, name, description, diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 24bd47a915f..e38d6553080 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -2,9 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderPlugin, ProviderRuntimeModel } from "./types.js"; const resolvePluginProvidersMock = vi.fn((_: unknown) => [] as ProviderPlugin[]); +const resolveOwningPluginIdsForProviderMock = vi.fn( + (_: unknown) => undefined as string[] | undefined, +); vi.mock("./providers.js", () => ({ resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never), + resolveOwningPluginIdsForProvider: (params: unknown) => + resolveOwningPluginIdsForProviderMock(params as never), })); import { @@ -41,6 +46,8 @@ describe("provider-runtime", () => { beforeEach(() => { resolvePluginProvidersMock.mockReset(); resolvePluginProvidersMock.mockReturnValue([]); + resolveOwningPluginIdsForProviderMock.mockReset(); + resolveOwningPluginIdsForProviderMock.mockReturnValue(undefined); }); it("matches providers by alias for runtime hook lookup", () => { @@ -56,9 +63,13 @@ describe("provider-runtime", () => { const plugin = resolveProviderRuntimePlugin({ provider: "Open Router" }); expect(plugin?.id).toBe("openrouter"); - expect(resolvePluginProvidersMock).toHaveBeenCalledWith( + expect(resolveOwningPluginIdsForProviderMock).toHaveBeenCalledWith( expect.objectContaining({ provider: "Open Router", + }), + ); + expect(resolvePluginProvidersMock).toHaveBeenCalledWith( + expect.objectContaining({ bundledProviderAllowlistCompat: true, bundledProviderVitestCompat: true, }), diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index e7ee62d8ebf..9e5104f7f86 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -1,6 +1,6 @@ import { normalizeProviderId } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; -import { resolvePluginProviders } from "./providers.js"; +import { resolveOwningPluginIdsForProvider, resolvePluginProviders } from "./providers.js"; import type { ProviderAugmentModelCatalogContext, ProviderBuildMissingAuthMessageContext, @@ -60,9 +60,15 @@ export function resolveProviderRuntimePlugin(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }): ProviderPlugin | undefined { - return resolveProviderPluginsForHooks(params).find((plugin) => - matchesProviderId(plugin, params.provider), - ); + return resolveProviderPluginsForHooks({ + ...params, + onlyPluginIds: resolveOwningPluginIdsForProvider({ + provider: params.provider, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }), + }).find((plugin) => matchesProviderId(plugin, params.provider)); } export function runProviderDynamicModel(params: { diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index 86ffb8e5ffc..a601336e5b9 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -1,18 +1,28 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resolvePluginProviders } from "./providers.js"; +import { resolveOwningPluginIdsForProvider, resolvePluginProviders } from "./providers.js"; const loadOpenClawPluginsMock = vi.fn(); +const loadPluginManifestRegistryMock = vi.fn(); vi.mock("./loader.js", () => ({ loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args), })); +vi.mock("./manifest-registry.js", () => ({ + loadPluginManifestRegistry: (...args: unknown[]) => loadPluginManifestRegistryMock(...args), +})); + describe("resolvePluginProviders", () => { beforeEach(() => { loadOpenClawPluginsMock.mockReset(); loadOpenClawPluginsMock.mockReturnValue({ providers: [{ pluginId: "google", provider: { id: "demo-provider" } }], }); + loadPluginManifestRegistryMock.mockReset(); + loadPluginManifestRegistryMock.mockReturnValue({ + plugins: [], + diagnostics: [], + }); }); it("forwards an explicit env to plugin loading", () => { @@ -86,4 +96,18 @@ describe("resolvePluginProviders", () => { expect(allow).toContain("google"); expect(allow).not.toContain("google-gemini-cli-auth"); }); + + it("maps provider ids to owning plugin ids via manifests", () => { + loadPluginManifestRegistryMock.mockReturnValue({ + plugins: [ + { id: "minimax", providers: ["minimax", "minimax-portal"] }, + { id: "openai", providers: ["openai", "openai-codex"] }, + ], + diagnostics: [], + }); + + expect(resolveOwningPluginIdsForProvider({ provider: "minimax-portal" })).toEqual(["minimax"]); + expect(resolveOwningPluginIdsForProvider({ provider: "openai-codex" })).toEqual(["openai"]); + expect(resolveOwningPluginIdsForProvider({ provider: "gemini-cli" })).toBeUndefined(); + }); }); diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index c1de0680359..e3215f2c6da 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -1,7 +1,9 @@ +import { normalizeProviderId } from "../agents/model-selection.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { withBundledPluginAllowlistCompat } from "./bundled-compat.js"; import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; +import { loadPluginManifestRegistry } from "./manifest-registry.js"; import type { ProviderPlugin } from "./types.js"; const log = createSubsystemLogger("plugins"); @@ -86,6 +88,32 @@ function withBundledProviderVitestCompat(params: { }, }; } + +export function resolveOwningPluginIdsForProvider(params: { + provider: string; + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; +}): string[] | undefined { + const normalizedProvider = normalizeProviderId(params.provider); + if (!normalizedProvider) { + return undefined; + } + + const registry = loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + const pluginIds = registry.plugins + .filter((plugin) => + plugin.providers.some((providerId) => normalizeProviderId(providerId) === normalizedProvider), + ) + .map((plugin) => plugin.id); + + return pluginIds.length > 0 ? pluginIds : undefined; +} + export function resolvePluginProviders(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 3b133642313..685858a9b6e 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -337,8 +337,6 @@ export type ProviderResolvedUsageAuth = { * This hook runs after `resolveUsageAuth` succeeds. Core still owns summary * fan-out, timeout wrapping, filtering, and formatting; the provider plugin * owns the provider-specific HTTP request + response normalization. - * - * Return `null`/`undefined` to fall back to legacy core fetchers. */ export type ProviderFetchUsageSnapshotContext = { config: OpenClawConfig; @@ -499,6 +497,12 @@ export type ProviderPlugin = { label: string; docsPath?: string; aliases?: string[]; + /** + * Provider-related env vars shown in onboarding/search/help surfaces. + * + * Keep entries in preferred display order. This can include direct auth env + * vars or setup inputs such as OAuth client id/secret vars. + */ envVars?: string[]; auth: ProviderAuthMethod[]; /** @@ -584,10 +588,9 @@ export type ProviderPlugin = { /** * Usage/billing auth resolution hook. * - * Called by provider-usage surfaces (`/usage`, status snapshots, reporting) - * before OpenClaw falls back to legacy core auth resolution. Use this when a - * provider's usage endpoint needs provider-owned token extraction, blob - * parsing, or alias handling. + * Called by provider-usage surfaces (`/usage`, status snapshots, reporting). + * Use this when a provider's usage endpoint needs provider-owned token + * extraction, blob parsing, or alias handling. */ resolveUsageAuth?: ( ctx: ProviderResolveUsageAuthContext, diff --git a/src/secrets/provider-env-vars.test.ts b/src/secrets/provider-env-vars.test.ts index 6e5b78f6643..6405d322e2f 100644 --- a/src/secrets/provider-env-vars.test.ts +++ b/src/secrets/provider-env-vars.test.ts @@ -10,10 +10,12 @@ describe("provider env vars", () => { expect(listKnownProviderAuthEnvVarNames()).toEqual( expect.arrayContaining(["GITHUB_TOKEN", "GH_TOKEN", "ANTHROPIC_OAUTH_TOKEN"]), ); - expect(listKnownSecretEnvVarNames()).not.toEqual(listKnownProviderAuthEnvVarNames()); - expect(listKnownSecretEnvVarNames()).not.toEqual( + expect(listKnownSecretEnvVarNames()).toEqual( expect.arrayContaining(["GITHUB_TOKEN", "GH_TOKEN", "ANTHROPIC_OAUTH_TOKEN"]), ); + expect(listKnownProviderAuthEnvVarNames()).toEqual( + expect.arrayContaining(["MINIMAX_CODE_PLAN_KEY"]), + ); expect(listKnownSecretEnvVarNames()).not.toContain("OPENCLAW_API_KEY"); }); diff --git a/src/secrets/provider-env-vars.ts b/src/secrets/provider-env-vars.ts index 88900893376..af89b57bf8d 100644 --- a/src/secrets/provider-env-vars.ts +++ b/src/secrets/provider-env-vars.ts @@ -1,50 +1,42 @@ -export const PROVIDER_ENV_VARS: Record = { - openai: ["OPENAI_API_KEY"], - anthropic: ["ANTHROPIC_API_KEY"], +import { BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES } from "../plugins/bundled-provider-auth-env-vars.js"; + +const CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES = { + chutes: ["CHUTES_OAUTH_TOKEN", "CHUTES_API_KEY"], google: ["GEMINI_API_KEY"], - minimax: ["MINIMAX_API_KEY"], - "minimax-cn": ["MINIMAX_API_KEY"], - moonshot: ["MOONSHOT_API_KEY"], - "kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"], - synthetic: ["SYNTHETIC_API_KEY"], - venice: ["VENICE_API_KEY"], - zai: ["ZAI_API_KEY", "Z_AI_API_KEY"], - xiaomi: ["XIAOMI_API_KEY"], - openrouter: ["OPENROUTER_API_KEY"], - "cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"], + voyage: ["VOYAGE_API_KEY"], + groq: ["GROQ_API_KEY"], + deepgram: ["DEEPGRAM_API_KEY"], + cerebras: ["CEREBRAS_API_KEY"], litellm: ["LITELLM_API_KEY"], - "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"], - opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], - "opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], - together: ["TOGETHER_API_KEY"], - huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"], - qianfan: ["QIANFAN_API_KEY"], - xai: ["XAI_API_KEY"], - mistral: ["MISTRAL_API_KEY"], - kilocode: ["KILOCODE_API_KEY"], - modelstudio: ["MODELSTUDIO_API_KEY"], - volcengine: ["VOLCANO_ENGINE_API_KEY"], - byteplus: ["BYTEPLUS_API_KEY"], +} as const; + +/** + * Provider auth env candidates used by generic auth resolution. + * + * Order matters: the first non-empty value wins for helpers such as + * `resolveEnvApiKey()`. Bundled providers source this from plugin manifest + * metadata so auth probes do not need to load plugin runtime. + */ +export const PROVIDER_AUTH_ENV_VAR_CANDIDATES: Record = { + ...BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES, + ...CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES, }; -const EXTRA_PROVIDER_AUTH_ENV_VARS = [ - "VOYAGE_API_KEY", - "GROQ_API_KEY", - "DEEPGRAM_API_KEY", - "CEREBRAS_API_KEY", - "NVIDIA_API_KEY", - "COPILOT_GITHUB_TOKEN", - "GH_TOKEN", - "GITHUB_TOKEN", - "ANTHROPIC_OAUTH_TOKEN", - "CHUTES_OAUTH_TOKEN", - "CHUTES_API_KEY", - "QWEN_OAUTH_TOKEN", - "QWEN_PORTAL_API_KEY", - "MINIMAX_OAUTH_TOKEN", - "OLLAMA_API_KEY", - "VLLM_API_KEY", -] as const; +/** + * Provider env vars used for onboarding/default secret refs and broad secret + * scrubbing. This can include non-model providers and may intentionally choose + * a different preferred first env var than auth resolution. + */ +export const PROVIDER_ENV_VARS: Record = { + ...PROVIDER_AUTH_ENV_VAR_CANDIDATES, + anthropic: ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"], + chutes: ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"], + google: ["GEMINI_API_KEY"], + "minimax-cn": ["MINIMAX_API_KEY"], + xai: ["XAI_API_KEY"], +}; + +const EXTRA_PROVIDER_AUTH_ENV_VARS = ["MINIMAX_CODE_PLAN_KEY"] as const; const KNOWN_SECRET_ENV_VARS = [ ...new Set(Object.values(PROVIDER_ENV_VARS).flatMap((keys) => keys)), @@ -53,7 +45,11 @@ const KNOWN_SECRET_ENV_VARS = [ // OPENCLAW_API_KEY authenticates the local OpenClaw bridge itself and must // remain available to child bridge/runtime processes. const KNOWN_PROVIDER_AUTH_ENV_VARS = [ - ...new Set([...KNOWN_SECRET_ENV_VARS, ...EXTRA_PROVIDER_AUTH_ENV_VARS]), + ...new Set([ + ...Object.values(PROVIDER_AUTH_ENV_VAR_CANDIDATES).flatMap((keys) => keys), + ...KNOWN_SECRET_ENV_VARS, + ...EXTRA_PROVIDER_AUTH_ENV_VARS, + ]), ]; export function listKnownProviderAuthEnvVarNames(): string[] { From 3b26da4b820a246e1d0f06c93bb07d23f6eff781 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:26:38 -0700 Subject: [PATCH 156/943] CLI: route gateway status before program registration --- src/cli/program/routes.test.ts | 72 ++++++++++++++++++++++++++++++++++ src/cli/program/routes.ts | 48 +++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index 0eb92333c0a..896dcb6757a 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -5,6 +5,7 @@ const runConfigGetMock = vi.hoisted(() => vi.fn(async () => {})); const runConfigUnsetMock = vi.hoisted(() => vi.fn(async () => {})); const modelsListCommandMock = vi.hoisted(() => vi.fn(async () => {})); const modelsStatusCommandMock = vi.hoisted(() => vi.fn(async () => {})); +const gatewayStatusCommandMock = vi.hoisted(() => vi.fn(async () => {})); vi.mock("../config-cli.js", () => ({ runConfigGet: runConfigGetMock, @@ -16,6 +17,10 @@ vi.mock("../../commands/models.js", () => ({ modelsStatusCommand: modelsStatusCommandMock, })); +vi.mock("../../commands/gateway-status.js", () => ({ + gatewayStatusCommand: gatewayStatusCommandMock, +})); + describe("program routes", () => { beforeEach(() => { vi.clearAllMocks(); @@ -48,6 +53,73 @@ describe("program routes", () => { expect(shouldLoad(["node", "openclaw", "health", "--json"])).toBe(false); }); + it("matches gateway status route without plugin preload", () => { + const route = expectRoute(["gateway", "status"]); + expect(route?.loadPlugins).toBeUndefined(); + }); + + it("returns false for gateway status route when option values are missing", async () => { + await expectRunFalse(["gateway", "status"], ["node", "openclaw", "gateway", "status", "--url"]); + await expectRunFalse( + ["gateway", "status"], + ["node", "openclaw", "gateway", "status", "--token"], + ); + await expectRunFalse( + ["gateway", "status"], + ["node", "openclaw", "gateway", "status", "--password"], + ); + await expectRunFalse( + ["gateway", "status"], + ["node", "openclaw", "gateway", "status", "--timeout"], + ); + await expectRunFalse(["gateway", "status"], ["node", "openclaw", "gateway", "status", "--ssh"]); + await expectRunFalse( + ["gateway", "status"], + ["node", "openclaw", "gateway", "status", "--ssh-identity"], + ); + }); + + it("passes parsed gateway status flags through", async () => { + const route = expectRoute(["gateway", "status"]); + await expect( + route?.run([ + "node", + "openclaw", + "--profile", + "work", + "gateway", + "status", + "--url", + "ws://127.0.0.1:18789", + "--token", + "abc", + "--password", + "def", + "--timeout", + "5000", + "--ssh", + "user@host", + "--ssh-identity", + "~/.ssh/id_test", + "--ssh-auto", + "--json", + ]), + ).resolves.toBe(true); + expect(gatewayStatusCommandMock).toHaveBeenCalledWith( + { + url: "ws://127.0.0.1:18789", + token: "abc", + password: "def", + timeout: "5000", + json: true, + ssh: "user@host", + sshIdentity: "~/.ssh/id_test", + sshAuto: true, + }, + expect.any(Object), + ); + }); + it("returns false when status timeout flag value is missing", async () => { await expectRunFalse(["status"], ["node", "openclaw", "status", "--timeout"]); }); diff --git a/src/cli/program/routes.ts b/src/cli/program/routes.ts index 52e0d8f8446..353c9b8f11d 100644 --- a/src/cli/program/routes.ts +++ b/src/cli/program/routes.ts @@ -53,6 +53,53 @@ const routeStatus: RouteSpec = { }, }; +const routeGatewayStatus: RouteSpec = { + match: (path) => path[0] === "gateway" && path[1] === "status", + run: async (argv) => { + const url = getFlagValue(argv, "--url"); + if (url === null) { + return false; + } + const token = getFlagValue(argv, "--token"); + if (token === null) { + return false; + } + const password = getFlagValue(argv, "--password"); + if (password === null) { + return false; + } + const timeout = getFlagValue(argv, "--timeout"); + if (timeout === null) { + return false; + } + const ssh = getFlagValue(argv, "--ssh"); + if (ssh === null) { + return false; + } + const sshIdentity = getFlagValue(argv, "--ssh-identity"); + if (sshIdentity === null) { + return false; + } + const sshAuto = hasFlag(argv, "--ssh-auto"); + const json = hasFlag(argv, "--json"); + const { gatewayStatusCommand } = await import("../../commands/gateway-status.js"); + await gatewayStatusCommand( + { + url: url ?? undefined, + token: token ?? undefined, + password: password ?? undefined, + timeout: timeout ?? undefined, + json, + ssh: ssh ?? undefined, + sshIdentity: sshIdentity ?? undefined, + sshAuto, + }, + defaultRuntime, + ); + return true; + }, +}; + const routeSessions: RouteSpec = { // Fast-path only bare `sessions`; subcommands (e.g. `sessions cleanup`) // must fall through to Commander so nested handlers run. @@ -251,6 +298,7 @@ const routeModelsStatus: RouteSpec = { const routes: RouteSpec[] = [ routeHealth, routeStatus, + routeGatewayStatus, routeSessions, routeAgentsList, routeMemoryStatus, From ae7f18e5033def8b4d49faca96cee7269223536b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:28:11 -0700 Subject: [PATCH 157/943] feat: add remote openshell sandbox mode --- CHANGELOG.md | 2 +- docs/gateway/configuration-reference.md | 8 +- docs/gateway/sandboxing.md | 52 +- extensions/openshell/src/backend.ts | 67 ++- extensions/openshell/src/config.test.ts | 13 + extensions/openshell/src/config.ts | 11 + extensions/openshell/src/fs-bridge.ts | 39 +- .../openshell/src/remote-fs-bridge.test.ts | 191 ++++++ extensions/openshell/src/remote-fs-bridge.ts | 550 ++++++++++++++++++ src/agents/apply-patch.test.ts | 42 ++ src/agents/apply-patch.ts | 6 +- src/agents/sandbox-media-paths.test.ts | 25 +- src/agents/sandbox-media-paths.ts | 15 +- src/agents/sandbox/fs-bridge.ts | 2 +- .../test-helpers/host-sandbox-fs-bridge.ts | 20 + 15 files changed, 1008 insertions(+), 35 deletions(-) create mode 100644 extensions/openshell/src/remote-fs-bridge.test.ts create mode 100644 extensions/openshell/src/remote-fs-bridge.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 98208595e0c..260d393c3cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ Docs: https://docs.openclaw.ai - Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc. - Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus. - secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. -- Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend in mirror mode, and make sandbox list/recreate/prune backend-aware instead of Docker-only. +- Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend with `mirror` and `remote` workspace modes, and make sandbox list/recreate/prune backend-aware instead of Docker-only. ### Fixes diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 78e58edc085..dbfc2b5dccb 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1117,7 +1117,7 @@ See [Typing Indicators](/concepts/typing-indicators). ### `agents.defaults.sandbox` -Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway/sandboxing) for the full guide. +Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing) for the full guide. ```json5 { @@ -1125,6 +1125,7 @@ Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway defaults: { sandbox: { mode: "non-main", // off | non-main | all + backend: "docker", // docker | openshell scope: "agent", // session | agent | shared workspaceAccess: "none", // none | ro | rw workspaceRoot: "~/.openclaw/sandboxes", @@ -1260,6 +1261,11 @@ noVNC observer access uses VNC auth by default and OpenClaw emits a short-lived
+When `backend: "openshell"` is selected, runtime-specific settings move to +`plugins.entries.openshell.config` (for example `mode: "mirror" | "remote"` and +`remoteWorkspaceDir`). Browser sandboxing and `sandbox.docker.binds` are +currently Docker-only. + Build images: ```bash diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index d62af2f4f7d..0e2219de14f 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -7,7 +7,7 @@ status: active # Sandboxing -OpenClaw can run **tools inside Docker containers** to reduce blast radius. +OpenClaw can run **tools inside sandbox backends** to reduce blast radius. This is **optional** and controlled by configuration (`agents.defaults.sandbox` or `agents.list[].sandbox`). If sandboxing is off, tools run on the host. The Gateway stays on the host; tool execution runs in an isolated sandbox @@ -54,6 +54,54 @@ Not sandboxed: - `"agent"`: one container per agent. - `"shared"`: one container shared by all sandboxed sessions. +## Backend + +`agents.defaults.sandbox.backend` controls **which runtime** provides the sandbox: + +- `"docker"` (default): local Docker-backed sandbox runtime. +- `"openshell"`: OpenShell-backed sandbox runtime provided by the bundled `openshell` plugin. + +OpenShell-specific config lives under `plugins.entries.openshell.config`. + +```json5 +{ + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "openshell", + scope: "session", + workspaceAccess: "rw", + }, + }, + }, + plugins: { + entries: { + openshell: { + enabled: true, + config: { + from: "openclaw", + mode: "remote", // mirror | remote + remoteWorkspaceDir: "/sandbox", + remoteAgentWorkspaceDir: "/agent", + }, + }, + }, + }, +} +``` + +OpenShell modes: + +- `mirror` (default): local workspace stays canonical. OpenClaw syncs local files into OpenShell before exec and syncs the remote workspace back after exec. +- `remote`: OpenShell workspace is canonical after the sandbox is created. OpenClaw seeds the remote workspace once from the local workspace, then file tools and exec run directly against the remote sandbox without syncing changes back. + +Current OpenShell limitations: + +- sandbox browser is not supported yet +- `sandbox.docker.binds` is not supported on the OpenShell backend +- Docker-specific runtime knobs under `sandbox.docker.*` still apply only to the Docker backend + ## Workspace access `agents.defaults.sandbox.workspaceAccess` controls **what the sandbox can see**: @@ -116,7 +164,7 @@ Security notes: ## Images + setup -Default image: `openclaw-sandbox:bookworm-slim` +Default Docker image: `openclaw-sandbox:bookworm-slim` Build it once: diff --git a/extensions/openshell/src/backend.ts b/extensions/openshell/src/backend.ts index 48f730946d4..85c3d415904 100644 --- a/extensions/openshell/src/backend.ts +++ b/extensions/openshell/src/backend.ts @@ -24,6 +24,7 @@ import { import { resolveOpenShellPluginConfig, type ResolvedOpenShellPluginConfig } from "./config.js"; import { createOpenShellFsBridge } from "./fs-bridge.js"; import { replaceDirectoryContents } from "./mirror.js"; +import { createOpenShellRemoteFsBridge } from "./remote-fs-bridge.js"; type CreateOpenShellSandboxBackendFactoryParams = { pluginConfig: ResolvedOpenShellPluginConfig; @@ -34,6 +35,7 @@ type PendingExec = { }; export type OpenShellSandboxBackend = SandboxBackendHandle & { + mode: "mirror" | "remote"; remoteWorkspaceDir: string; remoteAgentWorkspaceDir: string; runRemoteShellScript(params: SandboxBackendCommandParams): Promise; @@ -109,6 +111,7 @@ async function createOpenShellSandboxBackend(params: { runtimeLabel: sandboxName, workdir: params.pluginConfig.remoteWorkspaceDir, env: params.createParams.cfg.docker.env, + mode: params.pluginConfig.mode, configLabel: params.pluginConfig.from, configLabelKind: "Source", buildExecSpec: async ({ command, workdir, env, usePty }) => { @@ -125,10 +128,15 @@ async function createOpenShellSandboxBackend(params: { }, runShellCommand: async (command) => await impl.runRemoteShellScript(command), createFsBridge: ({ sandbox }) => - createOpenShellFsBridge({ - sandbox, - backend: impl.asHandle(), - }), + params.pluginConfig.mode === "remote" + ? createOpenShellRemoteFsBridge({ + sandbox, + backend: impl.asHandle(), + }) + : createOpenShellFsBridge({ + sandbox, + backend: impl.asHandle(), + }), remoteWorkspaceDir: params.pluginConfig.remoteWorkspaceDir, remoteAgentWorkspaceDir: params.pluginConfig.remoteAgentWorkspaceDir, runRemoteShellScript: async (command) => await impl.runRemoteShellScript(command), @@ -139,6 +147,7 @@ async function createOpenShellSandboxBackend(params: { class OpenShellSandboxBackendImpl { private ensurePromise: Promise | null = null; + private remoteSeedPending = false; constructor( private readonly params: { @@ -157,6 +166,7 @@ class OpenShellSandboxBackendImpl { runtimeLabel: this.params.execContext.sandboxName, workdir: this.params.remoteWorkspaceDir, env: this.params.createParams.cfg.docker.env, + mode: this.params.execContext.config.mode, configLabel: this.params.execContext.config.from, configLabelKind: "Source", remoteWorkspaceDir: this.params.remoteWorkspaceDir, @@ -175,10 +185,15 @@ class OpenShellSandboxBackendImpl { }, runShellCommand: async (command) => await self.runRemoteShellScript(command), createFsBridge: ({ sandbox }) => - createOpenShellFsBridge({ - sandbox, - backend: self.asHandle(), - }), + this.params.execContext.config.mode === "remote" + ? createOpenShellRemoteFsBridge({ + sandbox, + backend: self.asHandle(), + }) + : createOpenShellFsBridge({ + sandbox, + backend: self.asHandle(), + }), runRemoteShellScript: async (command) => await self.runRemoteShellScript(command), syncLocalPathToRemote: async (localPath, remotePath) => await self.syncLocalPathToRemote(localPath, remotePath), @@ -192,7 +207,11 @@ class OpenShellSandboxBackendImpl { usePty: boolean; }): Promise<{ argv: string[]; token: PendingExec }> { await this.ensureSandboxExists(); - await this.syncWorkspaceToRemote(); + if (this.params.execContext.config.mode === "mirror") { + await this.syncWorkspaceToRemote(); + } else { + await this.maybeSeedRemoteWorkspace(); + } const sshSession = await createOpenShellSshSession({ context: this.params.execContext, }); @@ -218,7 +237,9 @@ class OpenShellSandboxBackendImpl { async finalizeExec(token?: PendingExec): Promise { try { - await this.syncWorkspaceFromRemote(); + if (this.params.execContext.config.mode === "mirror") { + await this.syncWorkspaceFromRemote(); + } } finally { if (token?.sshSession) { await disposeOpenShellSshSession(token.sshSession); @@ -230,6 +251,13 @@ class OpenShellSandboxBackendImpl { params: SandboxBackendCommandParams, ): Promise { await this.ensureSandboxExists(); + await this.maybeSeedRemoteWorkspace(); + return await this.runRemoteShellScriptInternal(params); + } + + private async runRemoteShellScriptInternal( + params: SandboxBackendCommandParams, + ): Promise { const session = await createOpenShellSshSession({ context: this.params.execContext, }); @@ -254,6 +282,7 @@ class OpenShellSandboxBackendImpl { async syncLocalPathToRemote(localPath: string, remotePath: string): Promise { await this.ensureSandboxExists(); + await this.maybeSeedRemoteWorkspace(); const stats = await fs.lstat(localPath).catch(() => null); if (!stats) { await this.runRemoteShellScript({ @@ -340,10 +369,11 @@ class OpenShellSandboxBackendImpl { if (createResult.code !== 0) { throw new Error(createResult.stderr.trim() || "openshell sandbox create failed"); } + this.remoteSeedPending = true; } private async syncWorkspaceToRemote(): Promise { - await this.runRemoteShellScript({ + await this.runRemoteShellScriptInternal({ script: 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +', args: [this.params.remoteWorkspaceDir], }); @@ -357,7 +387,7 @@ class OpenShellSandboxBackendImpl { path.resolve(this.params.createParams.agentWorkspaceDir) !== path.resolve(this.params.createParams.workspaceDir) ) { - await this.runRemoteShellScript({ + await this.runRemoteShellScriptInternal({ script: 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +', args: [this.params.remoteAgentWorkspaceDir], }); @@ -413,6 +443,19 @@ class OpenShellSandboxBackendImpl { throw new Error(result.stderr.trim() || "openshell sandbox upload failed"); } } + + private async maybeSeedRemoteWorkspace(): Promise { + if (!this.remoteSeedPending) { + return; + } + this.remoteSeedPending = false; + try { + await this.syncWorkspaceToRemote(); + } catch (error) { + this.remoteSeedPending = true; + throw error; + } + } } function resolveOpenShellPluginConfigFromConfig( diff --git a/extensions/openshell/src/config.test.ts b/extensions/openshell/src/config.test.ts index 66734ca43e0..f46fec1cd46 100644 --- a/extensions/openshell/src/config.test.ts +++ b/extensions/openshell/src/config.test.ts @@ -4,6 +4,7 @@ import { resolveOpenShellPluginConfig } from "./config.js"; describe("openshell plugin config", () => { it("applies defaults", () => { expect(resolveOpenShellPluginConfig(undefined)).toEqual({ + mode: "mirror", command: "openshell", gateway: undefined, gatewayEndpoint: undefined, @@ -18,6 +19,10 @@ describe("openshell plugin config", () => { }); }); + it("accepts remote mode", () => { + expect(resolveOpenShellPluginConfig({ mode: "remote" }).mode).toBe("remote"); + }); + it("rejects relative remote paths", () => { expect(() => resolveOpenShellPluginConfig({ @@ -25,4 +30,12 @@ describe("openshell plugin config", () => { }), ).toThrow("OpenShell remote path must be absolute"); }); + + it("rejects unknown mode", () => { + expect(() => + resolveOpenShellPluginConfig({ + mode: "bogus", + }), + ).toThrow("mode must be one of mirror, remote"); + }); }); diff --git a/extensions/openshell/src/config.ts b/extensions/openshell/src/config.ts index 53e5f06584b..58b40180cd9 100644 --- a/extensions/openshell/src/config.ts +++ b/extensions/openshell/src/config.ts @@ -2,6 +2,7 @@ import path from "node:path"; import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/core"; export type OpenShellPluginConfig = { + mode?: string; command?: string; gateway?: string; gatewayEndpoint?: string; @@ -16,6 +17,7 @@ export type OpenShellPluginConfig = { }; export type ResolvedOpenShellPluginConfig = { + mode: "mirror" | "remote"; command: string; gateway?: string; gatewayEndpoint?: string; @@ -30,6 +32,7 @@ export type ResolvedOpenShellPluginConfig = { }; const DEFAULT_COMMAND = "openshell"; +const DEFAULT_MODE = "mirror"; const DEFAULT_SOURCE = "openclaw"; const DEFAULT_REMOTE_WORKSPACE_DIR = "/sandbox"; const DEFAULT_REMOTE_AGENT_WORKSPACE_DIR = "/agent"; @@ -99,6 +102,7 @@ export function createOpenShellPluginConfigSchema(): OpenClawPluginConfigSchema }; } const allowedKeys = new Set([ + "mode", "command", "gateway", "gatewayEndpoint", @@ -156,6 +160,7 @@ export function createOpenShellPluginConfigSchema(): OpenClawPluginConfigSchema return { success: true, data: { + mode: trimString(value.mode), command: trimString(value.command), gateway: trimString(value.gateway), gatewayEndpoint: trimString(value.gatewayEndpoint), @@ -178,6 +183,7 @@ export function createOpenShellPluginConfigSchema(): OpenClawPluginConfigSchema additionalProperties: false, properties: { command: { type: "string" }, + mode: { type: "string", enum: ["mirror", "remote"] }, gateway: { type: "string" }, gatewayEndpoint: { type: "string" }, from: { type: "string" }, @@ -203,7 +209,12 @@ export function resolveOpenShellPluginConfig(value: unknown): ResolvedOpenShellP } const raw = parsed.data ?? {}; const cfg = (raw ?? {}) as OpenShellPluginConfig; + const mode = cfg.mode ?? DEFAULT_MODE; + if (mode !== "mirror" && mode !== "remote") { + throw new Error(`Invalid openshell plugin config: mode must be one of mirror, remote`); + } return { + mode, command: cfg.command ?? DEFAULT_COMMAND, gateway: cfg.gateway, gatewayEndpoint: cfg.gatewayEndpoint, diff --git a/extensions/openshell/src/fs-bridge.ts b/extensions/openshell/src/fs-bridge.ts index b9ab9b01549..00257e81be4 100644 --- a/extensions/openshell/src/fs-bridge.ts +++ b/extensions/openshell/src/fs-bridge.ts @@ -43,13 +43,14 @@ class OpenShellFsBridge implements SandboxFsBridge { signal?: AbortSignal; }): Promise { const target = this.resolveTarget(params); + const hostPath = this.requireHostPath(target); await assertLocalPathSafety({ target, root: target.mountHostRoot, allowMissingLeaf: false, allowFinalSymlinkForUnlink: false, }); - return await fsPromises.readFile(target.hostPath); + return await fsPromises.readFile(hostPath); } async writeFile(params: { @@ -61,6 +62,7 @@ class OpenShellFsBridge implements SandboxFsBridge { signal?: AbortSignal; }): Promise { const target = this.resolveTarget(params); + const hostPath = this.requireHostPath(target); this.ensureWritable(target, "write files"); await assertLocalPathSafety({ target, @@ -71,21 +73,22 @@ class OpenShellFsBridge implements SandboxFsBridge { const buffer = Buffer.isBuffer(params.data) ? params.data : Buffer.from(params.data, params.encoding ?? "utf8"); - const parentDir = path.dirname(target.hostPath); + const parentDir = path.dirname(hostPath); if (params.mkdir !== false) { await fsPromises.mkdir(parentDir, { recursive: true }); } const tempPath = path.join( parentDir, - `.openclaw-openshell-write-${path.basename(target.hostPath)}-${process.pid}-${Date.now()}`, + `.openclaw-openshell-write-${path.basename(hostPath)}-${process.pid}-${Date.now()}`, ); await fsPromises.writeFile(tempPath, buffer); - await fsPromises.rename(tempPath, target.hostPath); - await this.backend.syncLocalPathToRemote(target.hostPath, target.containerPath); + await fsPromises.rename(tempPath, hostPath); + await this.backend.syncLocalPathToRemote(hostPath, target.containerPath); } async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise { const target = this.resolveTarget(params); + const hostPath = this.requireHostPath(target); this.ensureWritable(target, "create directories"); await assertLocalPathSafety({ target, @@ -93,7 +96,7 @@ class OpenShellFsBridge implements SandboxFsBridge { allowMissingLeaf: true, allowFinalSymlinkForUnlink: false, }); - await fsPromises.mkdir(target.hostPath, { recursive: true }); + await fsPromises.mkdir(hostPath, { recursive: true }); await this.backend.runRemoteShellScript({ script: 'mkdir -p -- "$1"', args: [target.containerPath], @@ -109,6 +112,7 @@ class OpenShellFsBridge implements SandboxFsBridge { signal?: AbortSignal; }): Promise { const target = this.resolveTarget(params); + const hostPath = this.requireHostPath(target); this.ensureWritable(target, "remove files"); await assertLocalPathSafety({ target, @@ -116,7 +120,7 @@ class OpenShellFsBridge implements SandboxFsBridge { allowMissingLeaf: params.force !== false, allowFinalSymlinkForUnlink: true, }); - await fsPromises.rm(target.hostPath, { + await fsPromises.rm(hostPath, { recursive: params.recursive ?? false, force: params.force !== false, }); @@ -138,6 +142,8 @@ class OpenShellFsBridge implements SandboxFsBridge { }): Promise { const from = this.resolveTarget({ filePath: params.from, cwd: params.cwd }); const to = this.resolveTarget({ filePath: params.to, cwd: params.cwd }); + const fromHostPath = this.requireHostPath(from); + const toHostPath = this.requireHostPath(to); this.ensureWritable(from, "rename files"); this.ensureWritable(to, "rename files"); await assertLocalPathSafety({ @@ -152,8 +158,8 @@ class OpenShellFsBridge implements SandboxFsBridge { allowMissingLeaf: true, allowFinalSymlinkForUnlink: false, }); - await fsPromises.mkdir(path.dirname(to.hostPath), { recursive: true }); - await movePathWithCopyFallback({ from: from.hostPath, to: to.hostPath }); + await fsPromises.mkdir(path.dirname(toHostPath), { recursive: true }); + await movePathWithCopyFallback({ from: fromHostPath, to: toHostPath }); await this.backend.runRemoteShellScript({ script: 'mkdir -p -- "$(dirname -- "$2")" && mv -- "$1" "$2"', args: [from.containerPath, to.containerPath], @@ -167,7 +173,8 @@ class OpenShellFsBridge implements SandboxFsBridge { signal?: AbortSignal; }): Promise { const target = this.resolveTarget(params); - const stats = await fsPromises.lstat(target.hostPath).catch(() => null); + const hostPath = this.requireHostPath(target); + const stats = await fsPromises.lstat(hostPath).catch(() => null); if (!stats) { return null; } @@ -190,6 +197,15 @@ class OpenShellFsBridge implements SandboxFsBridge { } } + private requireHostPath(target: ResolvedMountPath): string { + if (!target.hostPath) { + throw new Error( + `OpenShell mirror bridge requires a local host path: ${target.containerPath}`, + ); + } + return target.hostPath; + } + private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedMountPath { const workspaceRoot = path.resolve(this.sandbox.workspaceDir); const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir); @@ -282,6 +298,9 @@ async function assertLocalPathSafety(params: { allowMissingLeaf: boolean; allowFinalSymlinkForUnlink: boolean; }): Promise { + if (!params.target.hostPath) { + throw new Error(`Missing local host path for ${params.target.containerPath}`); + } const canonicalRoot = await fsPromises .realpath(params.root) .catch(() => path.resolve(params.root)); diff --git a/extensions/openshell/src/remote-fs-bridge.test.ts b/extensions/openshell/src/remote-fs-bridge.test.ts new file mode 100644 index 00000000000..5a245e1d8fb --- /dev/null +++ b/extensions/openshell/src/remote-fs-bridge.test.ts @@ -0,0 +1,191 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createSandboxTestContext } from "../../../src/agents/sandbox/test-fixtures.js"; +import type { OpenShellSandboxBackend } from "./backend.js"; +import { createOpenShellRemoteFsBridge } from "./remote-fs-bridge.js"; + +const tempDirs: string[] = []; + +async function makeTempDir(prefix: string) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +function translateRemotePath(value: string, roots: { workspace: string; agent: string }) { + if (value === "/sandbox" || value.startsWith("/sandbox/")) { + return path.join(roots.workspace, value.slice("/sandbox".length)); + } + if (value === "/agent" || value.startsWith("/agent/")) { + return path.join(roots.agent, value.slice("/agent".length)); + } + return value; +} + +async function runLocalShell(params: { + script: string; + args?: string[]; + stdin?: Buffer | string; + allowFailure?: boolean; + roots: { workspace: string; agent: string }; +}) { + const translatedArgs = (params.args ?? []).map((arg) => translateRemotePath(arg, params.roots)); + const script = normalizeScriptForLocalShell(params.script); + const result = await new Promise<{ stdout: Buffer; stderr: Buffer; code: number }>( + (resolve, reject) => { + const child = spawn("/bin/sh", ["-c", script, "openshell-test", ...translatedArgs], { + stdio: ["pipe", "pipe", "pipe"], + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); + child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); + child.on("error", reject); + child.on("close", (code) => { + const result = { + stdout: Buffer.concat(stdoutChunks), + stderr: Buffer.concat(stderrChunks), + code: code ?? 0, + }; + if (result.code !== 0 && !params.allowFailure) { + reject( + new Error( + result.stderr.toString("utf8").trim() || `script exited with code ${result.code}`, + ), + ); + return; + } + resolve(result); + }); + if (params.stdin !== undefined) { + child.stdin.end(params.stdin); + return; + } + child.stdin.end(); + }, + ); + return { + ...result, + stdout: Buffer.from(rewriteLocalPaths(result.stdout.toString("utf8"), params.roots), "utf8"), + }; +} + +function createBackendMock(roots: { workspace: string; agent: string }): OpenShellSandboxBackend { + return { + id: "openshell", + runtimeId: "openshell-test", + runtimeLabel: "openshell-test", + workdir: "/sandbox", + env: {}, + mode: "remote", + remoteWorkspaceDir: "/sandbox", + remoteAgentWorkspaceDir: "/agent", + buildExecSpec: vi.fn(), + runShellCommand: vi.fn(), + runRemoteShellScript: vi.fn( + async (params) => + await runLocalShell({ + ...params, + roots, + }), + ), + syncLocalPathToRemote: vi.fn().mockResolvedValue(undefined), + } as unknown as OpenShellSandboxBackend; +} + +function rewriteLocalPaths(value: string, roots: { workspace: string; agent: string }) { + return value.replaceAll(roots.workspace, "/sandbox").replaceAll(roots.agent, "/agent"); +} + +function normalizeScriptForLocalShell(script: string) { + return script + .replace( + 'stats=$(stat -c "%F|%h" -- "$1")', + `stats=$(python3 - "$1" <<'PY' +import os, stat, sys +st = os.stat(sys.argv[1]) +kind = 'directory' if stat.S_ISDIR(st.st_mode) else 'regular file' if stat.S_ISREG(st.st_mode) else 'other' +print(f"{kind}|{st.st_nlink}") +PY +)`, + ) + .replace( + 'stat -c "%F|%s|%Y" -- "$1"', + `python3 - "$1" <<'PY' +import os, stat, sys +st = os.stat(sys.argv[1]) +kind = 'directory' if stat.S_ISDIR(st.st_mode) else 'regular file' if stat.S_ISREG(st.st_mode) else 'other' +print(f"{kind}|{st.st_size}|{int(st.st_mtime)}") +PY`, + ); +} + +describe("openshell remote fs bridge", () => { + it("writes, reads, renames, and removes files without local host paths", async () => { + const workspaceDir = await makeTempDir("openclaw-openshell-remote-local-"); + const remoteWorkspaceDir = await makeTempDir("openclaw-openshell-remote-workspace-"); + const remoteAgentDir = await makeTempDir("openclaw-openshell-remote-agent-"); + const remoteWorkspaceRealDir = await fs.realpath(remoteWorkspaceDir); + const remoteAgentRealDir = await fs.realpath(remoteAgentDir); + const backend = createBackendMock({ + workspace: remoteWorkspaceRealDir, + agent: remoteAgentRealDir, + }); + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir: workspaceDir, + containerWorkdir: "/sandbox", + }, + }); + + const bridge = createOpenShellRemoteFsBridge({ sandbox, backend }); + await bridge.writeFile({ + filePath: "nested/file.txt", + data: "hello", + mkdir: true, + }); + + expect(await fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "file.txt"), "utf8")).toBe( + "hello", + ); + expect(await fs.readdir(workspaceDir)).toEqual([]); + + const resolved = bridge.resolvePath({ filePath: "nested/file.txt" }); + expect(resolved.hostPath).toBeUndefined(); + expect(resolved.containerPath).toBe("/sandbox/nested/file.txt"); + expect(await bridge.readFile({ filePath: "nested/file.txt" })).toEqual(Buffer.from("hello")); + expect(await bridge.stat({ filePath: "nested/file.txt" })).toEqual( + expect.objectContaining({ + type: "file", + size: 5, + }), + ); + + await bridge.rename({ + from: "nested/file.txt", + to: "nested/renamed.txt", + }); + await expect( + fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "file.txt"), "utf8"), + ).rejects.toBeDefined(); + expect( + await fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "renamed.txt"), "utf8"), + ).toBe("hello"); + + await bridge.remove({ + filePath: "nested/renamed.txt", + }); + await expect( + fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "renamed.txt"), "utf8"), + ).rejects.toBeDefined(); + }); +}); diff --git a/extensions/openshell/src/remote-fs-bridge.ts b/extensions/openshell/src/remote-fs-bridge.ts new file mode 100644 index 00000000000..3560fa78f28 --- /dev/null +++ b/extensions/openshell/src/remote-fs-bridge.ts @@ -0,0 +1,550 @@ +import path from "node:path"; +import type { + SandboxContext, + SandboxFsBridge, + SandboxFsStat, + SandboxResolvedPath, +} from "openclaw/plugin-sdk/core"; +import { SANDBOX_PINNED_MUTATION_PYTHON } from "../../../src/agents/sandbox/fs-bridge-mutation-helper.js"; +import type { OpenShellSandboxBackend } from "./backend.js"; + +type ResolvedRemotePath = SandboxResolvedPath & { + writable: boolean; + mountRootPath: string; + source: "workspace" | "agent"; +}; + +type MountInfo = { + containerRoot: string; + writable: boolean; + source: "workspace" | "agent"; +}; + +export function createOpenShellRemoteFsBridge(params: { + sandbox: SandboxContext; + backend: OpenShellSandboxBackend; +}): SandboxFsBridge { + return new OpenShellRemoteFsBridge(params.sandbox, params.backend); +} + +class OpenShellRemoteFsBridge implements SandboxFsBridge { + constructor( + private readonly sandbox: SandboxContext, + private readonly backend: OpenShellSandboxBackend, + ) {} + + resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath { + const target = this.resolveTarget(params); + return { + relativePath: target.relativePath, + containerPath: target.containerPath, + }; + } + + async readFile(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + const canonical = await this.resolveCanonicalPath({ + containerPath: target.containerPath, + action: "read files", + }); + await this.assertNoHardlinkedFile({ + containerPath: canonical, + action: "read files", + signal: params.signal, + }); + const result = await this.runRemoteScript({ + script: 'set -eu\ncat -- "$1"', + args: [canonical], + signal: params.signal, + }); + return result.stdout; + } + + async writeFile(params: { + filePath: string; + cwd?: string; + data: Buffer | string; + encoding?: BufferEncoding; + mkdir?: boolean; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "write files"); + const pinned = await this.resolvePinnedParent({ + containerPath: target.containerPath, + action: "write files", + requireWritable: true, + }); + await this.assertNoHardlinkedFile({ + containerPath: target.containerPath, + action: "write files", + signal: params.signal, + }); + const buffer = Buffer.isBuffer(params.data) + ? params.data + : Buffer.from(params.data, params.encoding ?? "utf8"); + await this.runMutation({ + args: [ + "write", + pinned.mountRootPath, + pinned.relativeParentPath, + pinned.basename, + params.mkdir !== false ? "1" : "0", + ], + stdin: buffer, + signal: params.signal, + }); + } + + async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "create directories"); + const relativePath = path.posix.relative(target.mountRootPath, target.containerPath); + if (relativePath.startsWith("..") || path.posix.isAbsolute(relativePath)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot create directories: ${target.containerPath}`, + ); + } + await this.runMutation({ + args: ["mkdirp", target.mountRootPath, relativePath === "." ? "" : relativePath], + signal: params.signal, + }); + } + + async remove(params: { + filePath: string; + cwd?: string; + recursive?: boolean; + force?: boolean; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "remove files"); + const exists = await this.remotePathExists(target.containerPath, params.signal); + if (!exists) { + if (params.force === false) { + throw new Error(`Sandbox path not found; cannot remove files: ${target.containerPath}`); + } + return; + } + const pinned = await this.resolvePinnedParent({ + containerPath: target.containerPath, + action: "remove files", + requireWritable: true, + allowFinalSymlinkForUnlink: true, + }); + await this.runMutation({ + args: [ + "remove", + pinned.mountRootPath, + pinned.relativeParentPath, + pinned.basename, + params.recursive ? "1" : "0", + params.force === false ? "0" : "1", + ], + signal: params.signal, + allowFailure: params.force !== false, + }); + } + + async rename(params: { + from: string; + to: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const from = this.resolveTarget({ filePath: params.from, cwd: params.cwd }); + const to = this.resolveTarget({ filePath: params.to, cwd: params.cwd }); + this.ensureWritable(from, "rename files"); + this.ensureWritable(to, "rename files"); + const fromPinned = await this.resolvePinnedParent({ + containerPath: from.containerPath, + action: "rename files", + requireWritable: true, + allowFinalSymlinkForUnlink: true, + }); + const toPinned = await this.resolvePinnedParent({ + containerPath: to.containerPath, + action: "rename files", + requireWritable: true, + }); + await this.runMutation({ + args: [ + "rename", + fromPinned.mountRootPath, + fromPinned.relativeParentPath, + fromPinned.basename, + toPinned.mountRootPath, + toPinned.relativeParentPath, + toPinned.basename, + "1", + ], + signal: params.signal, + }); + } + + async stat(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + const exists = await this.remotePathExists(target.containerPath, params.signal); + if (!exists) { + return null; + } + const canonical = await this.resolveCanonicalPath({ + containerPath: target.containerPath, + action: "stat files", + signal: params.signal, + }); + await this.assertNoHardlinkedFile({ + containerPath: canonical, + action: "stat files", + signal: params.signal, + }); + const result = await this.runRemoteScript({ + script: 'set -eu\nstat -c "%F|%s|%Y" -- "$1"', + args: [canonical], + signal: params.signal, + }); + const output = result.stdout.toString("utf8").trim(); + const [kindRaw = "", sizeRaw = "0", mtimeRaw = "0"] = output.split("|"); + return { + type: kindRaw === "directory" ? "directory" : kindRaw === "regular file" ? "file" : "other", + size: Number(sizeRaw), + mtimeMs: Number(mtimeRaw) * 1000, + }; + } + + private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedRemotePath { + const workspaceRoot = path.resolve(this.sandbox.workspaceDir); + const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir); + const workspaceContainerRoot = normalizeContainerPath(this.backend.remoteWorkspaceDir); + const agentContainerRoot = normalizeContainerPath(this.backend.remoteAgentWorkspaceDir); + const mounts: MountInfo[] = [ + { + containerRoot: workspaceContainerRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "workspace", + }, + ]; + if ( + this.sandbox.workspaceAccess !== "none" && + path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir) + ) { + mounts.push({ + containerRoot: agentContainerRoot, + writable: this.sandbox.workspaceAccess === "rw", + source: "agent", + }); + } + + const input = params.filePath.trim(); + const inputPosix = input.replace(/\\/g, "/"); + const maybeContainerMount = path.posix.isAbsolute(inputPosix) + ? this.resolveMountByContainerPath(mounts, normalizeContainerPath(inputPosix)) + : null; + if (maybeContainerMount) { + return this.toResolvedPath({ + mount: maybeContainerMount, + containerPath: normalizeContainerPath(inputPosix), + }); + } + + const hostCwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot; + const hostCandidate = path.isAbsolute(input) + ? path.resolve(input) + : path.resolve(hostCwd, input); + if (isPathInside(workspaceRoot, hostCandidate)) { + const relative = toPosixRelative(workspaceRoot, hostCandidate); + return this.toResolvedPath({ + mount: mounts[0]!, + containerPath: relative + ? path.posix.join(workspaceContainerRoot, relative) + : workspaceContainerRoot, + }); + } + if (mounts[1] && isPathInside(agentRoot, hostCandidate)) { + const relative = toPosixRelative(agentRoot, hostCandidate); + return this.toResolvedPath({ + mount: mounts[1], + containerPath: relative + ? path.posix.join(agentContainerRoot, relative) + : agentContainerRoot, + }); + } + + if (params.cwd) { + const cwdPosix = params.cwd.replace(/\\/g, "/"); + if (path.posix.isAbsolute(cwdPosix)) { + const cwdContainer = normalizeContainerPath(cwdPosix); + const cwdMount = this.resolveMountByContainerPath(mounts, cwdContainer); + if (cwdMount) { + return this.toResolvedPath({ + mount: cwdMount, + containerPath: normalizeContainerPath(path.posix.resolve(cwdContainer, inputPosix)), + }); + } + } + } + + throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${params.filePath}`); + } + + private toResolvedPath(params: { mount: MountInfo; containerPath: string }): ResolvedRemotePath { + const relative = path.posix.relative(params.mount.containerRoot, params.containerPath); + if (relative.startsWith("..") || path.posix.isAbsolute(relative)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot access: ${params.containerPath}`, + ); + } + return { + relativePath: + params.mount.source === "workspace" + ? relative === "." + ? "" + : relative + : relative === "." + ? params.mount.containerRoot + : `${params.mount.containerRoot}/${relative}`, + containerPath: params.containerPath, + writable: params.mount.writable, + mountRootPath: params.mount.containerRoot, + source: params.mount.source, + }; + } + + private resolveMountByContainerPath( + mounts: MountInfo[], + containerPath: string, + ): MountInfo | null { + const ordered = [...mounts].toSorted((a, b) => b.containerRoot.length - a.containerRoot.length); + for (const mount of ordered) { + if (isPathInsideContainerRoot(mount.containerRoot, containerPath)) { + return mount; + } + } + return null; + } + + private ensureWritable(target: ResolvedRemotePath, action: string) { + if (this.sandbox.workspaceAccess !== "rw" || !target.writable) { + throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`); + } + } + + private async remotePathExists(containerPath: string, signal?: AbortSignal): Promise { + const result = await this.runRemoteScript({ + script: 'if [ -e "$1" ] || [ -L "$1" ]; then printf "1\\n"; else printf "0\\n"; fi', + args: [containerPath], + signal, + }); + return result.stdout.toString("utf8").trim() === "1"; + } + + private async resolveCanonicalPath(params: { + containerPath: string; + action: string; + allowFinalSymlinkForUnlink?: boolean; + signal?: AbortSignal; + }): Promise { + const script = [ + "set -eu", + 'target="$1"', + 'allow_final="$2"', + 'suffix=""', + 'probe="$target"', + 'if [ "$allow_final" = "1" ] && [ -L "$target" ]; then probe=$(dirname -- "$target"); fi', + 'cursor="$probe"', + 'while [ ! -e "$cursor" ] && [ ! -L "$cursor" ]; do', + ' parent=$(dirname -- "$cursor")', + ' if [ "$parent" = "$cursor" ]; then break; fi', + ' base=$(basename -- "$cursor")', + ' suffix="/$base$suffix"', + ' cursor="$parent"', + "done", + 'canonical=$(readlink -f -- "$cursor")', + 'printf "%s%s\\n" "$canonical" "$suffix"', + ].join("\n"); + const result = await this.runRemoteScript({ + script, + args: [params.containerPath, params.allowFinalSymlinkForUnlink ? "1" : "0"], + signal: params.signal, + }); + const canonical = normalizeContainerPath(result.stdout.toString("utf8").trim()); + const mount = this.resolveMountByContainerPath( + [ + { + containerRoot: normalizeContainerPath(this.backend.remoteWorkspaceDir), + writable: this.sandbox.workspaceAccess === "rw", + source: "workspace", + }, + ...(this.sandbox.workspaceAccess !== "none" && + path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir) + ? [ + { + containerRoot: normalizeContainerPath(this.backend.remoteAgentWorkspaceDir), + writable: this.sandbox.workspaceAccess === "rw", + source: "agent" as const, + }, + ] + : []), + ], + canonical, + ); + if (!mount) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, + ); + } + return canonical; + } + + private async assertNoHardlinkedFile(params: { + containerPath: string; + action: string; + signal?: AbortSignal; + }): Promise { + const result = await this.runRemoteScript({ + script: [ + 'if [ ! -e "$1" ] && [ ! -L "$1" ]; then exit 0; fi', + 'stats=$(stat -c "%F|%h" -- "$1")', + 'printf "%s\\n" "$stats"', + ].join("\n"), + args: [params.containerPath], + signal: params.signal, + allowFailure: true, + }); + const output = result.stdout.toString("utf8").trim(); + if (!output) { + return; + } + const [kind = "", linksRaw = "1"] = output.split("|"); + if (kind === "regular file" && Number(linksRaw) > 1) { + throw new Error( + `Hardlinked path is not allowed under sandbox mount root: ${params.containerPath}`, + ); + } + } + + private async resolvePinnedParent(params: { + containerPath: string; + action: string; + requireWritable?: boolean; + allowFinalSymlinkForUnlink?: boolean; + }): Promise<{ mountRootPath: string; relativeParentPath: string; basename: string }> { + const basename = path.posix.basename(params.containerPath); + if (!basename || basename === "." || basename === "/") { + throw new Error(`Invalid sandbox entry target: ${params.containerPath}`); + } + const canonicalParent = await this.resolveCanonicalPath({ + containerPath: normalizeContainerPath(path.posix.dirname(params.containerPath)), + action: params.action, + allowFinalSymlinkForUnlink: params.allowFinalSymlinkForUnlink, + }); + const mount = this.resolveMountByContainerPath( + [ + { + containerRoot: normalizeContainerPath(this.backend.remoteWorkspaceDir), + writable: this.sandbox.workspaceAccess === "rw", + source: "workspace", + }, + ...(this.sandbox.workspaceAccess !== "none" && + path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir) + ? [ + { + containerRoot: normalizeContainerPath(this.backend.remoteAgentWorkspaceDir), + writable: this.sandbox.workspaceAccess === "rw", + source: "agent" as const, + }, + ] + : []), + ], + canonicalParent, + ); + if (!mount) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, + ); + } + if (params.requireWritable && !mount.writable) { + throw new Error( + `Sandbox path is read-only; cannot ${params.action}: ${params.containerPath}`, + ); + } + const relativeParentPath = path.posix.relative(mount.containerRoot, canonicalParent); + if (relativeParentPath.startsWith("..") || path.posix.isAbsolute(relativeParentPath)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, + ); + } + return { + mountRootPath: mount.containerRoot, + relativeParentPath: relativeParentPath === "." ? "" : relativeParentPath, + basename, + }; + } + + private async runMutation(params: { + args: string[]; + stdin?: Buffer | string; + signal?: AbortSignal; + allowFailure?: boolean; + }) { + await this.runRemoteScript({ + script: [ + "set -eu", + "python3 /dev/fd/3 \"$@\" 3<<'PY'", + SANDBOX_PINNED_MUTATION_PYTHON, + "PY", + ].join("\n"), + args: params.args, + stdin: params.stdin, + signal: params.signal, + allowFailure: params.allowFailure, + }); + } + + private async runRemoteScript(params: { + script: string; + args?: string[]; + stdin?: Buffer | string; + signal?: AbortSignal; + allowFailure?: boolean; + }) { + return await this.backend.runRemoteShellScript({ + script: params.script, + args: params.args, + stdin: params.stdin, + signal: params.signal, + allowFailure: params.allowFailure, + }); + } +} + +function normalizeContainerPath(value: string): string { + const normalized = path.posix.normalize(value.trim() || "/"); + return normalized.startsWith("/") ? normalized : `/${normalized}`; +} + +function isPathInsideContainerRoot(root: string, candidate: string): boolean { + const normalizedRoot = normalizeContainerPath(root); + const normalizedCandidate = normalizeContainerPath(candidate); + return ( + normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`) + ); +} + +function isPathInside(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function toPosixRelative(root: string, candidate: string): string { + return path.relative(root, candidate).split(path.sep).filter(Boolean).join(path.posix.sep); +} diff --git a/src/agents/apply-patch.test.ts b/src/agents/apply-patch.test.ts index 1f305379b5d..5182dfdf0af 100644 --- a/src/agents/apply-patch.test.ts +++ b/src/agents/apply-patch.test.ts @@ -361,4 +361,46 @@ describe("applyPatch", () => { } }); }); + + it("uses container paths when the sandbox bridge has no local host path", async () => { + const files = new Map([["/sandbox/source.txt", "before\n"]]); + const bridge = { + resolvePath: ({ filePath }: { filePath: string }) => ({ + relativePath: filePath, + containerPath: `/sandbox/${filePath}`, + }), + readFile: vi.fn(async ({ filePath }: { filePath: string }) => + Buffer.from(files.get(filePath) ?? "", "utf8"), + ), + writeFile: vi.fn(async ({ filePath, data }: { filePath: string; data: Buffer | string }) => { + files.set(filePath, Buffer.isBuffer(data) ? data.toString("utf8") : data); + }), + remove: vi.fn(async ({ filePath }: { filePath: string }) => { + files.delete(filePath); + }), + mkdirp: vi.fn(async () => {}), + }; + + const patch = `*** Begin Patch +*** Update File: source.txt +@@ +-before ++after +*** End Patch`; + + const result = await applyPatch(patch, { + cwd: "/local/workspace", + sandbox: { + root: "/local/workspace", + bridge: bridge as never, + }, + }); + + expect(files.get("/sandbox/source.txt")).toBe("after\n"); + expect(result.summary.modified).toEqual(["source.txt"]); + expect(bridge.readFile).toHaveBeenCalledWith({ + filePath: "/sandbox/source.txt", + cwd: "/local/workspace", + }); + }); }); diff --git a/src/agents/apply-patch.ts b/src/agents/apply-patch.ts index d7a5dc1e0ff..0fc612923c1 100644 --- a/src/agents/apply-patch.ts +++ b/src/agents/apply-patch.ts @@ -313,7 +313,7 @@ async function resolvePatchPath( filePath, cwd: options.cwd, }); - if (options.workspaceOnly !== false) { + if (options.workspaceOnly !== false && resolved.hostPath) { await assertSandboxPath({ filePath: resolved.hostPath, cwd: options.cwd, @@ -323,8 +323,8 @@ async function resolvePatchPath( }); } return { - resolved: resolved.hostPath, - display: resolved.relativePath || resolved.hostPath, + resolved: resolved.hostPath ?? resolved.containerPath, + display: resolved.relativePath || resolved.containerPath, }; } diff --git a/src/agents/sandbox-media-paths.test.ts b/src/agents/sandbox-media-paths.test.ts index 4179c2a68ef..0007e943fdd 100644 --- a/src/agents/sandbox-media-paths.test.ts +++ b/src/agents/sandbox-media-paths.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import { createSandboxBridgeReadFile } from "./sandbox-media-paths.js"; +import { + createSandboxBridgeReadFile, + resolveSandboxedBridgeMediaPath, +} from "./sandbox-media-paths.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; describe("createSandboxBridgeReadFile", () => { @@ -19,4 +22,24 @@ describe("createSandboxBridgeReadFile", () => { cwd: "/tmp/sandbox-root", }); }); + + it("falls back to container paths when the bridge has no host path", async () => { + const stat = vi.fn(async () => ({ type: "file", size: 1, mtimeMs: 1 })); + const resolved = await resolveSandboxedBridgeMediaPath({ + sandbox: { + root: "/tmp/sandbox-root", + bridge: { + resolvePath: ({ filePath }: { filePath: string }) => ({ + relativePath: filePath, + containerPath: `/sandbox/${filePath}`, + }), + stat, + } as unknown as SandboxFsBridge, + }, + mediaPath: "image.png", + }); + + expect(resolved).toEqual({ resolved: "/sandbox/image.png" }); + expect(stat).not.toHaveBeenCalled(); + }); }); diff --git a/src/agents/sandbox-media-paths.ts b/src/agents/sandbox-media-paths.ts index 3c6b2614c94..1c46f392482 100644 --- a/src/agents/sandbox-media-paths.ts +++ b/src/agents/sandbox-media-paths.ts @@ -44,8 +44,10 @@ export async function resolveSandboxedBridgeMediaPath(params: { }); try { const resolved = resolveDirect(); - await enforceWorkspaceBoundary(resolved.hostPath); - return { resolved: resolved.hostPath }; + if (resolved.hostPath) { + await enforceWorkspaceBoundary(resolved.hostPath); + } + return { resolved: resolved.hostPath ?? resolved.containerPath }; } catch (err) { const fallbackDir = params.inboundFallbackDir?.trim(); if (!fallbackDir) { @@ -67,7 +69,12 @@ export async function resolveSandboxedBridgeMediaPath(params: { filePath: fallbackPath, cwd: params.sandbox.root, }); - await enforceWorkspaceBoundary(resolvedFallback.hostPath); - return { resolved: resolvedFallback.hostPath, rewrittenFrom: filePath }; + if (resolvedFallback.hostPath) { + await enforceWorkspaceBoundary(resolvedFallback.hostPath); + } + return { + resolved: resolvedFallback.hostPath ?? resolvedFallback.containerPath, + rewrittenFrom: filePath, + }; } } diff --git a/src/agents/sandbox/fs-bridge.ts b/src/agents/sandbox/fs-bridge.ts index 16c307e053c..7941b2b6828 100644 --- a/src/agents/sandbox/fs-bridge.ts +++ b/src/agents/sandbox/fs-bridge.ts @@ -24,7 +24,7 @@ type RunCommandOptions = { }; export type SandboxResolvedPath = { - hostPath: string; + hostPath?: string; relativePath: string; containerPath: string; }; diff --git a/src/agents/test-helpers/host-sandbox-fs-bridge.ts b/src/agents/test-helpers/host-sandbox-fs-bridge.ts index 93bb34969a8..fc466f0ea67 100644 --- a/src/agents/test-helpers/host-sandbox-fs-bridge.ts +++ b/src/agents/test-helpers/host-sandbox-fs-bridge.ts @@ -10,10 +10,16 @@ export function createSandboxFsBridgeFromResolver( resolvePath: ({ filePath, cwd }) => resolvePath(filePath, cwd), readFile: async ({ filePath, cwd }) => { const target = resolvePath(filePath, cwd); + if (!target.hostPath) { + throw new Error(`Expected hostPath for ${target.containerPath}`); + } return fs.readFile(target.hostPath); }, writeFile: async ({ filePath, cwd, data, mkdir = true }) => { const target = resolvePath(filePath, cwd); + if (!target.hostPath) { + throw new Error(`Expected hostPath for ${target.containerPath}`); + } if (mkdir) { await fs.mkdir(path.dirname(target.hostPath), { recursive: true }); } @@ -22,10 +28,16 @@ export function createSandboxFsBridgeFromResolver( }, mkdirp: async ({ filePath, cwd }) => { const target = resolvePath(filePath, cwd); + if (!target.hostPath) { + throw new Error(`Expected hostPath for ${target.containerPath}`); + } await fs.mkdir(target.hostPath, { recursive: true }); }, remove: async ({ filePath, cwd, recursive, force }) => { const target = resolvePath(filePath, cwd); + if (!target.hostPath) { + throw new Error(`Expected hostPath for ${target.containerPath}`); + } await fs.rm(target.hostPath, { recursive: recursive ?? false, force: force ?? false, @@ -34,12 +46,20 @@ export function createSandboxFsBridgeFromResolver( rename: async ({ from, to, cwd }) => { const source = resolvePath(from, cwd); const target = resolvePath(to, cwd); + if (!source.hostPath || !target.hostPath) { + throw new Error( + `Expected hostPath for rename: ${source.containerPath} -> ${target.containerPath}`, + ); + } await fs.mkdir(path.dirname(target.hostPath), { recursive: true }); await fs.rename(source.hostPath, target.hostPath); }, stat: async ({ filePath, cwd }) => { try { const target = resolvePath(filePath, cwd); + if (!target.hostPath) { + throw new Error(`Expected hostPath for ${target.containerPath}`); + } const stats = await fs.stat(target.hostPath); return { type: stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other", From be8fef3840b03f9511f7153ad8bc93773477de45 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:35:56 -0700 Subject: [PATCH 158/943] docs: expand openshell sandbox docs --- docs/cli/sandbox.md | 56 ++++++++++++------ docs/gateway/configuration-reference.md | 46 +++++++++++++-- docs/gateway/sandboxing.md | 76 ++++++++++++++++++++++++- 3 files changed, 155 insertions(+), 23 deletions(-) diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index e8e4614a9ff..5ebac698175 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -1,17 +1,22 @@ --- title: Sandbox CLI -summary: "Manage sandbox containers and inspect effective sandbox policy" -read_when: "You are managing sandbox containers or debugging sandbox/tool-policy behavior." +summary: "Manage sandbox runtimes and inspect effective sandbox policy" +read_when: "You are managing sandbox runtimes or debugging sandbox/tool-policy behavior." status: active --- # Sandbox CLI -Manage Docker-based sandbox containers for isolated agent execution. +Manage sandbox runtimes for isolated agent execution. ## Overview -OpenClaw can run agents in isolated Docker containers for security. The `sandbox` commands help you manage these containers, especially after updates or configuration changes. +OpenClaw can run agents in isolated sandbox runtimes for security. The `sandbox` commands help you inspect and recreate those runtimes after updates or configuration changes. + +Today that usually means: + +- Docker sandbox containers +- OpenShell sandbox runtimes when `agents.defaults.sandbox.backend = "openshell"` ## Commands @@ -28,7 +33,7 @@ openclaw sandbox explain --json ### `openclaw sandbox list` -List all sandbox containers with their status and configuration. +List all sandbox runtimes with their status and configuration. ```bash openclaw sandbox list @@ -38,15 +43,16 @@ openclaw sandbox list --json # JSON output **Output includes:** -- Container name and status (running/stopped) -- Docker image and whether it matches config +- Runtime name and status +- Backend (`docker`, `openshell`, etc.) +- Config label and whether it matches current config - Age (time since creation) - Idle time (time since last use) - Associated session/agent ### `openclaw sandbox recreate` -Remove sandbox containers to force recreation with updated images/config. +Remove sandbox runtimes to force recreation with updated config. ```bash openclaw sandbox recreate --all # Recreate all containers @@ -64,11 +70,11 @@ openclaw sandbox recreate --all --force # Skip confirmation - `--browser`: Only recreate browser containers - `--force`: Skip confirmation prompt -**Important:** Containers are automatically recreated when the agent is next used. +**Important:** Runtimes are automatically recreated when the agent is next used. ## Use Cases -### After updating Docker images +### After updating a Docker image ```bash # Pull new image @@ -91,6 +97,21 @@ openclaw sandbox recreate --all openclaw sandbox recreate --all ``` +### After changing OpenShell source, policy, or mode + +```bash +# Edit config: +# - agents.defaults.sandbox.backend +# - plugins.entries.openshell.config.from +# - plugins.entries.openshell.config.mode +# - plugins.entries.openshell.config.policy + +openclaw sandbox recreate --all +``` + +For OpenShell `remote` mode, recreate deletes the canonical remote workspace +for that scope. The next run seeds it again from the local workspace. + ### After changing setupCommand ```bash @@ -108,16 +129,16 @@ openclaw sandbox recreate --agent alfred ## Why is this needed? -**Problem:** When you update sandbox Docker images or configuration: +**Problem:** When you update sandbox configuration: -- Existing containers continue running with old settings -- Containers are only pruned after 24h of inactivity -- Regularly-used agents keep old containers running indefinitely +- Existing runtimes continue running with old settings +- Runtimes are only pruned after 24h of inactivity +- Regularly-used agents keep old runtimes alive indefinitely -**Solution:** Use `openclaw sandbox recreate` to force removal of old containers. They'll be recreated automatically with current settings when next needed. +**Solution:** Use `openclaw sandbox recreate` to force removal of old runtimes. They'll be recreated automatically with current settings when next needed. -Tip: prefer `openclaw sandbox recreate` over manual `docker rm`. It uses the -Gateway’s container naming and avoids mismatches when scope/session keys change. +Tip: prefer `openclaw sandbox recreate` over manual backend-specific cleanup. +It uses the Gateway’s runtime registry and avoids mismatches when scope/session keys change. ## Configuration @@ -129,6 +150,7 @@ Sandbox settings live in `~/.openclaw/openclaw.json` under `agents.defaults.sand "defaults": { "sandbox": { "mode": "all", // off, non-main, all + "backend": "docker", // docker, openshell "scope": "agent", // session, agent, shared "docker": { "image": "openclaw-sandbox:bookworm-slim", diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index dbfc2b5dccb..951f99f1165 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1200,6 +1200,14 @@ Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing +**Backend:** + +- `docker`: local Docker runtime (default) +- `openshell`: OpenShell runtime + +When `backend: "openshell"` is selected, runtime-specific settings move to +`plugins.entries.openshell.config`. + **Workspace access:** - `none`: per-scope sandbox workspace under `~/.openclaw/sandboxes` @@ -1212,6 +1220,39 @@ Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing - `agent`: one container + workspace per agent (default) - `shared`: shared container and workspace (no cross-session isolation) +**OpenShell plugin config:** + +```json5 +{ + plugins: { + entries: { + openshell: { + enabled: true, + config: { + mode: "mirror", // mirror | remote + from: "openclaw", + remoteWorkspaceDir: "/sandbox", + remoteAgentWorkspaceDir: "/agent", + gateway: "lab", // optional + gatewayEndpoint: "https://lab.example", // optional + policy: "strict", // optional OpenShell policy id + providers: ["openai"], // optional + autoProviders: true, + timeoutSeconds: 120, + }, + }, + }, + }, +} +``` + +**OpenShell mode:** + +- `mirror`: seed remote from local before exec, sync back after exec; local workspace stays canonical +- `remote`: seed remote once when the sandbox is created, then keep the remote workspace canonical + +In `remote` mode, host-local edits made outside OpenClaw are not synced into the sandbox automatically after the seed step. + **`setupCommand`** runs once after container creation (via `sh -lc`). Needs network egress, writable root, root user. **Containers default to `network: "none"`** — set to `"bridge"` (or a custom bridge network) if the agent needs outbound access. @@ -1261,10 +1302,7 @@ noVNC observer access uses VNC auth by default and OpenClaw emits a short-lived -When `backend: "openshell"` is selected, runtime-specific settings move to -`plugins.entries.openshell.config` (for example `mode: "mirror" | "remote"` and -`remoteWorkspaceDir`). Browser sandboxing and `sandbox.docker.binds` are -currently Docker-only. +Browser sandboxing and `sandbox.docker.binds` are currently Docker-only. Build images: diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 0e2219de14f..db40b802832 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -59,7 +59,7 @@ Not sandboxed: `agents.defaults.sandbox.backend` controls **which runtime** provides the sandbox: - `"docker"` (default): local Docker-backed sandbox runtime. -- `"openshell"`: OpenShell-backed sandbox runtime provided by the bundled `openshell` plugin. +- `"openshell"`: OpenShell-backed sandbox runtime. OpenShell-specific config lives under `plugins.entries.openshell.config`. @@ -102,6 +102,72 @@ Current OpenShell limitations: - `sandbox.docker.binds` is not supported on the OpenShell backend - Docker-specific runtime knobs under `sandbox.docker.*` still apply only to the Docker backend +## OpenShell workspace modes + +OpenShell has two workspace models. This is the part that matters most in practice. + +### `mirror` + +Use `plugins.entries.openshell.config.mode: "mirror"` when you want the **local workspace to stay canonical**. + +Behavior: + +- Before `exec`, OpenClaw syncs the local workspace into the OpenShell sandbox. +- After `exec`, OpenClaw syncs the remote workspace back to the local workspace. +- File tools still operate through the sandbox bridge, but the local workspace remains the source of truth between turns. + +Use this when: + +- you edit files locally outside OpenClaw and want those changes to show up in the sandbox automatically +- you want the OpenShell sandbox to behave as much like the Docker backend as possible +- you want the host workspace to reflect sandbox writes after each exec turn + +Tradeoff: + +- extra sync cost before and after exec + +### `remote` + +Use `plugins.entries.openshell.config.mode: "remote"` when you want the **OpenShell workspace to become canonical**. + +Behavior: + +- When the sandbox is first created, OpenClaw seeds the remote workspace from the local workspace once. +- After that, `exec`, `read`, `write`, `edit`, and `apply_patch` operate directly against the remote OpenShell workspace. +- OpenClaw does **not** sync remote changes back into the local workspace after exec. +- Prompt-time media reads still work because file and media tools read through the sandbox bridge instead of assuming a local host path. + +Important consequences: + +- If you edit files on the host outside OpenClaw after the seed step, the remote sandbox will **not** see those changes automatically. +- If the sandbox is recreated, the remote workspace is seeded from the local workspace again. +- With `scope: "agent"` or `scope: "shared"`, that remote workspace is shared at that same scope. + +Use this when: + +- the sandbox should live primarily on the remote OpenShell side +- you want lower per-turn sync overhead +- you do not want host-local edits to silently overwrite remote sandbox state + +Choose `mirror` if you think of the sandbox as a temporary execution environment. +Choose `remote` if you think of the sandbox as the real workspace. + +## OpenShell lifecycle + +OpenShell sandboxes are still managed through the normal sandbox lifecycle: + +- `openclaw sandbox list` shows OpenShell runtimes as well as Docker runtimes +- `openclaw sandbox recreate` deletes the current runtime and lets OpenClaw recreate it on next use +- prune logic is backend-aware too + +For `remote` mode, recreate is especially important: + +- recreate deletes the canonical remote workspace for that scope +- the next use seeds a fresh remote workspace from the local workspace + +For `mirror` mode, recreate mainly resets the remote execution environment +because the local workspace remains canonical anyway. + ## Workspace access `agents.defaults.sandbox.workspaceAccess` controls **what the sandbox can see**: @@ -110,6 +176,12 @@ Current OpenShell limitations: - `"ro"`: mounts the agent workspace read-only at `/agent` (disables `write`/`edit`/`apply_patch`). - `"rw"`: mounts the agent workspace read/write at `/workspace`. +With the OpenShell backend: + +- `mirror` mode still uses the local workspace as the canonical source between exec turns +- `remote` mode uses the remote OpenShell workspace as the canonical source after the initial seed +- `workspaceAccess: "ro"` and `"none"` still restrict write behavior the same way + Inbound media is copied into the active sandbox workspace (`media/inbound/*`). Skills note: the `read` tool is sandbox-rooted. With `workspaceAccess: "none"`, OpenClaw mirrors eligible skills into the sandbox workspace (`.../skills`) so @@ -193,7 +265,7 @@ Sandboxed browser image: scripts/sandbox-browser-setup.sh ``` -By default, sandbox containers run with **no network**. +By default, Docker sandbox containers run with **no network**. Override with `agents.defaults.sandbox.docker.network`. The bundled sandbox browser image also applies conservative Chromium startup defaults From aa28d1c71138b2e2d85511e40fae5983e3ae621e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 03:38:51 +0000 Subject: [PATCH 159/943] feat: add firecrawl onboarding search plugin --- CHANGELOG.md | 1 + docs/refactor/firecrawl-extension.md | 260 ++++++++++ docs/tools/firecrawl.md | 86 +++- docs/tools/index.md | 2 +- docs/tools/web.md | 56 ++- extensions/firecrawl/index.test.ts | 100 ++++ extensions/firecrawl/index.ts | 20 + extensions/firecrawl/openclaw.plugin.json | 8 + extensions/firecrawl/package.json | 12 + extensions/firecrawl/src/config.ts | 159 +++++++ extensions/firecrawl/src/firecrawl-client.ts | 446 ++++++++++++++++++ .../firecrawl/src/firecrawl-scrape-tool.ts | 89 ++++ .../src/firecrawl-search-provider.ts | 63 +++ .../firecrawl/src/firecrawl-search-tool.ts | 76 +++ src/agents/tools/web-fetch-utils.ts | 37 +- src/agents/tools/web-fetch.ts | 122 +++-- src/agents/tools/web-tools.fetch.test.ts | 59 ++- src/commands/onboard-search.test.ts | 19 +- src/commands/onboard-search.ts | 15 +- src/config/config.web-search-provider.test.ts | 26 + src/config/schema.help.ts | 6 +- src/config/schema.labels.ts | 2 + src/config/types.tools.ts | 11 +- src/config/zod-schema.agent-runtime.ts | 8 + src/plugins/web-search-providers.test.ts | 1 + src/plugins/web-search-providers.ts | 1 + 26 files changed, 1593 insertions(+), 92 deletions(-) create mode 100644 docs/refactor/firecrawl-extension.md create mode 100644 extensions/firecrawl/index.test.ts create mode 100644 extensions/firecrawl/index.ts create mode 100644 extensions/firecrawl/openclaw.plugin.json create mode 100644 extensions/firecrawl/package.json create mode 100644 extensions/firecrawl/src/config.ts create mode 100644 extensions/firecrawl/src/firecrawl-client.ts create mode 100644 extensions/firecrawl/src/firecrawl-scrape-tool.ts create mode 100644 extensions/firecrawl/src/firecrawl-search-provider.ts create mode 100644 extensions/firecrawl/src/firecrawl-search-tool.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 260d393c3cb..07937512400 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327. - Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl. - Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029) +- Web tools/Firecrawl: add Firecrawl as an `onboard`/configure search provider via a bundled plugin, expose explicit `firecrawl_search` and `firecrawl_scrape` tools, and align core `web_fetch` fallback behavior with Firecrawl base-URL/env fallback plus guarded endpoint fetches. - Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob. - Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280. - Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898. diff --git a/docs/refactor/firecrawl-extension.md b/docs/refactor/firecrawl-extension.md new file mode 100644 index 00000000000..e25e010e7b1 --- /dev/null +++ b/docs/refactor/firecrawl-extension.md @@ -0,0 +1,260 @@ +--- +summary: "Design for an opt-in Firecrawl extension that adds search/scrape value without hardwiring Firecrawl into core defaults" +read_when: + - Designing Firecrawl integration work + - Evaluating web_search/web_fetch plugin seams + - Deciding whether Firecrawl belongs in core or as an extension +title: "Firecrawl Extension Design" +--- + +# Firecrawl Extension Design + +## Goal + +Ship Firecrawl as an **opt-in extension** that adds: + +- explicit Firecrawl tools for agents, +- optional Firecrawl-backed `web_search` integration, +- self-hosted support, +- stronger security defaults than the current core fallback path, + +without pushing Firecrawl into the default setup/onboarding path. + +## Why this shape + +Recent Firecrawl issues/PRs cluster into three buckets: + +1. **Release/schema drift** + - Several releases rejected `tools.web.fetch.firecrawl` even though docs and runtime code supported it. +2. **Security hardening** + - Current `fetchFirecrawlContent()` still posts to the Firecrawl endpoint with raw `fetch()`, while the main web-fetch path uses the SSRF guard. +3. **Product pressure** + - Users want Firecrawl-native search/scrape flows, especially for self-hosted/private setups. + - Maintainers explicitly rejected wiring Firecrawl deeply into core defaults, setup flow, and browser behavior. + +That combination argues for an extension, not more Firecrawl-specific logic in the default core path. + +## Design principles + +- **Opt-in, vendor-scoped**: no auto-enable, no setup hijack, no default tool-profile widening. +- **Extension owns Firecrawl-specific config**: prefer plugin config over growing `tools.web.*` again. +- **Useful on day one**: works even if core `web_search` / `web_fetch` seams stay unchanged. +- **Security-first**: endpoint fetches use the same guarded networking posture as other web tools. +- **Self-hosted-friendly**: config + env fallback, explicit base URL, no hosted-only assumptions. + +## Proposed extension + +Plugin id: `firecrawl` + +### MVP capabilities + +Register explicit tools: + +- `firecrawl_search` +- `firecrawl_scrape` + +Optional later: + +- `firecrawl_crawl` +- `firecrawl_map` + +Do **not** add Firecrawl browser automation in the first version. That was the part of PR #32543 that pulled Firecrawl too far into core behavior and raised the most maintainership concern. + +## Config shape + +Use plugin-scoped config: + +```json5 +{ + plugins: { + entries: { + firecrawl: { + enabled: true, + config: { + apiKey: "FIRECRAWL_API_KEY", + baseUrl: "https://api.firecrawl.dev", + timeoutSeconds: 60, + maxAgeMs: 172800000, + proxy: "auto", + storeInCache: true, + onlyMainContent: true, + search: { + enabled: true, + defaultLimit: 5, + sources: ["web"], + categories: [], + scrapeResults: false, + }, + scrape: { + formats: ["markdown"], + fallbackForWebFetchLikeUse: false, + }, + }, + }, + }, + }, +} +``` + +### Credential resolution + +Precedence: + +1. `plugins.entries.firecrawl.config.apiKey` +2. `FIRECRAWL_API_KEY` + +Base URL precedence: + +1. `plugins.entries.firecrawl.config.baseUrl` +2. `FIRECRAWL_BASE_URL` +3. `https://api.firecrawl.dev` + +### Compatibility bridge + +For the first release, the extension may also **read** existing core config at `tools.web.fetch.firecrawl.*` as a fallback source so existing users do not need to migrate immediately. + +Write path stays plugin-local. Do not keep expanding core Firecrawl config surfaces. + +## Tool design + +### `firecrawl_search` + +Inputs: + +- `query` +- `limit` +- `sources` +- `categories` +- `scrapeResults` +- `timeoutSeconds` + +Behavior: + +- Calls Firecrawl `v2/search` +- Returns normalized OpenClaw-friendly result objects: + - `title` + - `url` + - `snippet` + - `source` + - optional `content` +- Wraps result content as untrusted external content +- Cache key includes query + relevant provider params + +Why explicit tool first: + +- Works today without changing `tools.web.search.provider` +- Avoids current schema/loader constraints +- Gives users Firecrawl value immediately + +### `firecrawl_scrape` + +Inputs: + +- `url` +- `formats` +- `onlyMainContent` +- `maxAgeMs` +- `proxy` +- `storeInCache` +- `timeoutSeconds` + +Behavior: + +- Calls Firecrawl `v2/scrape` +- Returns markdown/text plus metadata: + - `title` + - `finalUrl` + - `status` + - `warning` +- Wraps extracted content the same way `web_fetch` does +- Shares cache semantics with web tool expectations where practical + +Why explicit scrape tool: + +- Sidesteps the unresolved `Readability -> Firecrawl -> basic HTML cleanup` ordering bug in core `web_fetch` +- Gives users a deterministic “always use Firecrawl” path for JS-heavy/bot-protected sites + +## What the extension should not do + +- No auto-adding `browser`, `web_search`, or `web_fetch` to `tools.alsoAllow` +- No default onboarding step in `openclaw setup` +- No Firecrawl-specific browser session lifecycle in core +- No change to built-in `web_fetch` fallback semantics in the extension MVP + +## Phase plan + +### Phase 1: extension-only, no core schema changes + +Implement: + +- `extensions/firecrawl/` +- plugin config schema +- `firecrawl_search` +- `firecrawl_scrape` +- tests for config resolution, endpoint selection, caching, error handling, and SSRF guard usage + +This phase is enough to ship real user value. + +### Phase 2: optional `web_search` provider integration + +Support `tools.web.search.provider = "firecrawl"` only after fixing two core constraints: + +1. `src/plugins/web-search-providers.ts` must load configured/installed web-search-provider plugins instead of a hardcoded bundled list. +2. `src/config/types.tools.ts` and `src/config/zod-schema.agent-runtime.ts` must stop hardcoding the provider enum in a way that blocks plugin-registered ids. + +Recommended shape: + +- keep built-in providers documented, +- allow any registered plugin provider id at runtime, +- validate provider-specific config via the provider plugin or a generic provider bag. + +### Phase 3: optional `web_fetch` provider seam + +Do this only if maintainers want vendor-specific fetch backends to participate in `web_fetch`. + +Needed core addition: + +- `registerWebFetchProvider` or equivalent fetch-backend seam + +Without that seam, the extension should keep `firecrawl_scrape` as an explicit tool rather than trying to patch built-in `web_fetch`. + +## Security requirements + +The extension must treat Firecrawl as a **trusted operator-configured endpoint**, but still harden transport: + +- Use SSRF-guarded fetch for the Firecrawl endpoint call, not raw `fetch()` +- Preserve self-hosted/private-network compatibility using the same trusted-web-tools endpoint policy used elsewhere +- Never log the API key +- Keep endpoint/base URL resolution explicit and predictable +- Treat Firecrawl-returned content as untrusted external content + +This mirrors the intent behind the SSRF hardening PRs without assuming Firecrawl is a hostile multi-tenant surface. + +## Why not a skill + +The repo already closed a Firecrawl skill PR in favor of ClawHub distribution. That is fine for optional user-installed prompt workflows, but it does not solve: + +- deterministic tool availability, +- provider-grade config/credential handling, +- self-hosted endpoint support, +- caching, +- stable typed outputs, +- security review on network behavior. + +This belongs as an extension, not a prompt-only skill. + +## Success criteria + +- Users can install/enable one extension and get reliable Firecrawl search/scrape without touching core defaults. +- Self-hosted Firecrawl works with config/env fallback. +- Extension endpoint fetches use guarded networking. +- No new Firecrawl-specific core onboarding/default behavior. +- Core can later adopt plugin-native `web_search` / `web_fetch` seams without redesigning the extension. + +## Recommended implementation order + +1. Build `firecrawl_scrape` +2. Build `firecrawl_search` +3. Add docs and examples +4. If desired, generalize `web_search` provider loading so the extension can back `web_search` +5. Only then consider a true `web_fetch` provider seam diff --git a/docs/tools/firecrawl.md b/docs/tools/firecrawl.md index 2cd90a06bf5..901890dfb0a 100644 --- a/docs/tools/firecrawl.md +++ b/docs/tools/firecrawl.md @@ -1,27 +1,71 @@ --- -summary: "Firecrawl fallback for web_fetch (anti-bot + cached extraction)" +summary: "Firecrawl search, scrape, and web_fetch fallback" read_when: - You want Firecrawl-backed web extraction - You need a Firecrawl API key + - You want Firecrawl as a web_search provider - You want anti-bot extraction for web_fetch title: "Firecrawl" --- # Firecrawl -OpenClaw can use **Firecrawl** as a fallback extractor for `web_fetch`. It is a hosted -content extraction service that supports bot circumvention and caching, which helps -with JS-heavy sites or pages that block plain HTTP fetches. +OpenClaw can use **Firecrawl** in three ways: + +- as the `web_search` provider +- as explicit plugin tools: `firecrawl_search` and `firecrawl_scrape` +- as a fallback extractor for `web_fetch` + +It is a hosted extraction/search service that supports bot circumvention and caching, +which helps with JS-heavy sites or pages that block plain HTTP fetches. ## Get an API key 1. Create a Firecrawl account and generate an API key. 2. Store it in config or set `FIRECRAWL_API_KEY` in the gateway environment. -## Configure Firecrawl +## Configure Firecrawl search ```json5 { + plugins: { + entries: { + firecrawl: { + enabled: true, + }, + }, + }, + tools: { + web: { + search: { + provider: "firecrawl", + firecrawl: { + apiKey: "FIRECRAWL_API_KEY_HERE", + baseUrl: "https://api.firecrawl.dev", + }, + }, + }, + }, +} +``` + +Notes: + +- Choosing Firecrawl in onboarding or `openclaw configure --section web` enables the bundled Firecrawl plugin automatically. +- `web_search` with Firecrawl supports `query` and `count`. +- For Firecrawl-specific controls like `sources`, `categories`, or result scraping, use `firecrawl_search`. + +## Configure Firecrawl scrape + web_fetch fallback + +```json5 +{ + plugins: { + entries: { + firecrawl: { + enabled: true, + }, + }, + }, tools: { web: { fetch: { @@ -44,6 +88,38 @@ Notes: - Firecrawl fallback attempts run only when an API key is available (`tools.web.fetch.firecrawl.apiKey` or `FIRECRAWL_API_KEY`). - `maxAgeMs` controls how old cached results can be (ms). Default is 2 days. +`firecrawl_scrape` reuses the same `tools.web.fetch.firecrawl.*` settings and env vars. + +## Firecrawl plugin tools + +### `firecrawl_search` + +Use this when you want Firecrawl-specific search controls instead of generic `web_search`. + +Core parameters: + +- `query` +- `count` +- `sources` +- `categories` +- `scrapeResults` +- `timeoutSeconds` + +### `firecrawl_scrape` + +Use this for JS-heavy or bot-protected pages where plain `web_fetch` is weak. + +Core parameters: + +- `url` +- `extractMode` +- `maxChars` +- `onlyMainContent` +- `maxAgeMs` +- `proxy` +- `storeInCache` +- `timeoutSeconds` + ## Stealth / bot circumvention Firecrawl exposes a **proxy mode** parameter for bot circumvention (`basic`, `stealth`, or `auto`). diff --git a/docs/tools/index.md b/docs/tools/index.md index bdd9b78456f..dbca6cd26bf 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -256,7 +256,7 @@ Enable with `tools.loopDetection.enabled: true` (default is `false`). ### `web_search` -Search the web using Perplexity, Brave, Gemini, Grok, or Kimi. +Search the web using Brave, Firecrawl, Gemini, Grok, Kimi, or Perplexity. Core parameters: diff --git a/docs/tools/web.md b/docs/tools/web.md index a2aa1d37bfd..7cc67c07710 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -1,5 +1,5 @@ --- -summary: "Web search + fetch tools (Brave, Gemini, Grok, Kimi, and Perplexity providers)" +summary: "Web search + fetch tools (Brave, Firecrawl, Gemini, Grok, Kimi, and Perplexity providers)" read_when: - You want to enable web_search or web_fetch - You need provider API key setup @@ -11,7 +11,7 @@ title: "Web Tools" OpenClaw ships two lightweight web tools: -- `web_search` — Search the web using Brave Search API, Gemini with Google Search grounding, Grok, Kimi, or Perplexity Search API. +- `web_search` — Search the web using Brave Search API, Firecrawl Search, Gemini with Google Search grounding, Grok, Kimi, or Perplexity Search API. - `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text). These are **not** browser automation. For JS-heavy sites or logins, use the @@ -24,18 +24,20 @@ These are **not** browser automation. For JS-heavy sites or logins, use the - `web_fetch` does a plain HTTP GET and extracts readable content (HTML → markdown/text). It does **not** execute JavaScript. - `web_fetch` is enabled by default (unless explicitly disabled). +- The bundled Firecrawl plugin also adds `firecrawl_search` and `firecrawl_scrape` when enabled. See [Brave Search setup](/brave-search) and [Perplexity Search setup](/perplexity) for provider-specific details. ## Choosing a search provider -| Provider | Result shape | Provider-specific filters | Notes | API key | -| ------------------------- | ---------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- | -| **Brave Search API** | Structured results with snippets | `country`, `language`, `ui_lang`, time | Supports Brave `llm-context` mode | `BRAVE_API_KEY` | -| **Gemini** | AI-synthesized answers + citations | — | Uses Google Search grounding | `GEMINI_API_KEY` | -| **Grok** | AI-synthesized answers + citations | — | Uses xAI web-grounded responses | `XAI_API_KEY` | -| **Kimi** | AI-synthesized answers + citations | — | Uses Moonshot web search | `KIMI_API_KEY` / `MOONSHOT_API_KEY` | -| **Perplexity Search API** | Structured results with snippets | `country`, `language`, time, `domain_filter` | Supports content extraction controls; OpenRouter uses Sonar compatibility path | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` | +| Provider | Result shape | Provider-specific filters | Notes | API key | +| ------------------------- | ---------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------- | +| **Brave Search API** | Structured results with snippets | `country`, `language`, `ui_lang`, time | Supports Brave `llm-context` mode | `BRAVE_API_KEY` | +| **Firecrawl Search** | Structured results with snippets | Use `firecrawl_search` for Firecrawl-specific search options | Best for pairing search with Firecrawl scraping/extraction | `FIRECRAWL_API_KEY` | +| **Gemini** | AI-synthesized answers + citations | — | Uses Google Search grounding | `GEMINI_API_KEY` | +| **Grok** | AI-synthesized answers + citations | — | Uses xAI web-grounded responses | `XAI_API_KEY` | +| **Kimi** | AI-synthesized answers + citations | — | Uses Moonshot web search | `KIMI_API_KEY` / `MOONSHOT_API_KEY` | +| **Perplexity Search API** | Structured results with snippets | `country`, `language`, time, `domain_filter` | Supports content extraction controls; OpenRouter uses Sonar compatibility path | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` | ### Auto-detection @@ -46,6 +48,7 @@ The table above is alphabetical. If no `provider` is explicitly set, runtime aut 3. **Grok** — `XAI_API_KEY` env var or `tools.web.search.grok.apiKey` config 4. **Kimi** — `KIMI_API_KEY` / `MOONSHOT_API_KEY` env var or `tools.web.search.kimi.apiKey` config 5. **Perplexity** — `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` config +6. **Firecrawl** — `FIRECRAWL_API_KEY` env var or `tools.web.search.firecrawl.apiKey` config If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one). @@ -86,6 +89,7 @@ See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quicks **Via config:** run `openclaw configure --section web`. It stores the key under the provider-specific config path: - Brave: `tools.web.search.apiKey` +- Firecrawl: `tools.web.search.firecrawl.apiKey` - Gemini: `tools.web.search.gemini.apiKey` - Grok: `tools.web.search.grok.apiKey` - Kimi: `tools.web.search.kimi.apiKey` @@ -96,6 +100,7 @@ All of these fields also support SecretRef objects. **Via environment:** set provider env vars in the Gateway process environment: - Brave: `BRAVE_API_KEY` +- Firecrawl: `FIRECRAWL_API_KEY` - Gemini: `GEMINI_API_KEY` - Grok: `XAI_API_KEY` - Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY` @@ -121,6 +126,34 @@ For a gateway install, put these in `~/.openclaw/.env` (or your service environm } ``` +**Firecrawl Search:** + +```json5 +{ + plugins: { + entries: { + firecrawl: { + enabled: true, + }, + }, + }, + tools: { + web: { + search: { + enabled: true, + provider: "firecrawl", + firecrawl: { + apiKey: "fc-...", // optional if FIRECRAWL_API_KEY is set + baseUrl: "https://api.firecrawl.dev", + }, + }, + }, + }, +} +``` + +When you choose Firecrawl in onboarding or `openclaw configure --section web`, OpenClaw enables the bundled Firecrawl plugin automatically so `web_search`, `firecrawl_search`, and `firecrawl_scrape` are all available. + **Brave LLM Context mode:** ```json5 @@ -234,6 +267,7 @@ Search the web using your configured provider. - `tools.web.search.enabled` must not be `false` (default: enabled) - API key for your chosen provider: - **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey` + - **Firecrawl**: `FIRECRAWL_API_KEY` or `tools.web.search.firecrawl.apiKey` - **Gemini**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey` - **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey` - **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey` @@ -260,7 +294,7 @@ Search the web using your configured provider. ### Tool parameters -All parameters work for Brave and for native Perplexity Search API unless noted. +Parameters depend on the selected provider. Perplexity's OpenRouter / Sonar compatibility path supports only `query` and `freshness`. If you set `tools.web.search.perplexity.baseUrl` / `model`, use `OPENROUTER_API_KEY`, or configure an `sk-or-...` key, Search API-only filters return explicit errors. @@ -279,6 +313,8 @@ If you set `tools.web.search.perplexity.baseUrl` / `model`, use `OPENROUTER_API_ | `max_tokens` | Total content budget, default 25000 (Perplexity only) | | `max_tokens_per_page` | Per-page token limit, default 2048 (Perplexity only) | +Firecrawl `web_search` supports `query` and `count`. For Firecrawl-specific controls like `sources`, `categories`, result scraping, or scrape timeout, use `firecrawl_search` from the bundled Firecrawl plugin. + **Examples:** ```javascript diff --git a/extensions/firecrawl/index.test.ts b/extensions/firecrawl/index.test.ts new file mode 100644 index 00000000000..084d3c0c055 --- /dev/null +++ b/extensions/firecrawl/index.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import plugin from "./index.js"; +import { __testing as firecrawlClientTesting } from "./src/firecrawl-client.js"; + +describe("firecrawl plugin", () => { + it("registers a web search provider and tools", () => { + const tools: Array<{ name: string }> = []; + const webSearchProviders: Array<{ id: string }> = []; + + plugin.register?.({ + config: {}, + registerTool(tool: { name: string }) { + tools.push(tool); + }, + registerWebSearchProvider(provider: { id: string }) { + webSearchProviders.push(provider); + }, + } as never); + + expect(webSearchProviders.map((provider) => provider.id)).toEqual(["firecrawl"]); + expect(tools.map((tool) => tool.name)).toEqual(["firecrawl_search", "firecrawl_scrape"]); + }); + + it("parses scrape payloads into wrapped external-content results", () => { + const result = firecrawlClientTesting.parseFirecrawlScrapePayload({ + payload: { + success: true, + data: { + markdown: "# Hello\n\nWorld", + metadata: { + title: "Example page", + sourceURL: "https://example.com/final", + statusCode: 200, + }, + }, + }, + url: "https://example.com/start", + extractMode: "text", + maxChars: 1000, + }); + + expect(result.finalUrl).toBe("https://example.com/final"); + expect(result.status).toBe(200); + expect(result.extractor).toBe("firecrawl"); + expect(typeof result.text).toBe("string"); + }); + + it("extracts search items from flexible Firecrawl payload shapes", () => { + const items = firecrawlClientTesting.resolveSearchItems({ + success: true, + data: [ + { + title: "Docs", + url: "https://docs.example.com/path", + description: "Reference docs", + markdown: "Body", + }, + ], + }); + + expect(items).toEqual([ + { + title: "Docs", + url: "https://docs.example.com/path", + description: "Reference docs", + content: "Body", + published: undefined, + siteName: "docs.example.com", + }, + ]); + }); + + it("extracts search items from Firecrawl v2 data.web payloads", () => { + const items = firecrawlClientTesting.resolveSearchItems({ + success: true, + data: { + web: [ + { + title: "API Platform - OpenAI", + url: "https://openai.com/api/", + description: "Build on the OpenAI API platform.", + markdown: "# API Platform", + position: 1, + }, + ], + }, + }); + + expect(items).toEqual([ + { + title: "API Platform - OpenAI", + url: "https://openai.com/api/", + description: "Build on the OpenAI API platform.", + content: "# API Platform", + published: undefined, + siteName: "openai.com", + }, + ]); + }); +}); diff --git a/extensions/firecrawl/index.ts b/extensions/firecrawl/index.ts new file mode 100644 index 00000000000..42bd1a3252f --- /dev/null +++ b/extensions/firecrawl/index.ts @@ -0,0 +1,20 @@ +import type { AnyAgentTool } from "../../src/agents/tools/common.js"; +import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; +import type { OpenClawPluginApi } from "../../src/plugins/types.js"; +import { createFirecrawlScrapeTool } from "./src/firecrawl-scrape-tool.js"; +import { createFirecrawlWebSearchProvider } from "./src/firecrawl-search-provider.js"; +import { createFirecrawlSearchTool } from "./src/firecrawl-search-tool.js"; + +const firecrawlPlugin = { + id: "firecrawl", + name: "Firecrawl Plugin", + description: "Bundled Firecrawl search and scrape plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerWebSearchProvider(createFirecrawlWebSearchProvider()); + api.registerTool(createFirecrawlSearchTool(api) as AnyAgentTool); + api.registerTool(createFirecrawlScrapeTool(api) as AnyAgentTool); + }, +}; + +export default firecrawlPlugin; diff --git a/extensions/firecrawl/openclaw.plugin.json b/extensions/firecrawl/openclaw.plugin.json new file mode 100644 index 00000000000..52289f0711a --- /dev/null +++ b/extensions/firecrawl/openclaw.plugin.json @@ -0,0 +1,8 @@ +{ + "id": "firecrawl", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/firecrawl/package.json b/extensions/firecrawl/package.json new file mode 100644 index 00000000000..e891b8293ba --- /dev/null +++ b/extensions/firecrawl/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/firecrawl-plugin", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw Firecrawl plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/firecrawl/src/config.ts b/extensions/firecrawl/src/config.ts new file mode 100644 index 00000000000..808b81891f1 --- /dev/null +++ b/extensions/firecrawl/src/config.ts @@ -0,0 +1,159 @@ +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { normalizeResolvedSecretInputString } from "../../../src/config/types.secrets.js"; +import { normalizeSecretInput } from "../../../src/utils/normalize-secret-input.js"; + +export const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev"; +export const DEFAULT_FIRECRAWL_SEARCH_TIMEOUT_SECONDS = 30; +export const DEFAULT_FIRECRAWL_SCRAPE_TIMEOUT_SECONDS = 60; +export const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000; + +type WebSearchConfig = NonNullable["web"] extends infer Web + ? Web extends { search?: infer Search } + ? Search + : undefined + : undefined; + +type WebFetchConfig = NonNullable["web"] extends infer Web + ? Web extends { fetch?: infer Fetch } + ? Fetch + : undefined + : undefined; + +type FirecrawlSearchConfig = + | { + apiKey?: unknown; + baseUrl?: string; + } + | undefined; + +type FirecrawlFetchConfig = + | { + apiKey?: unknown; + baseUrl?: string; + onlyMainContent?: boolean; + maxAgeMs?: number; + timeoutSeconds?: number; + } + | undefined; + +function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig { + const search = cfg?.tools?.web?.search; + if (!search || typeof search !== "object") { + return undefined; + } + return search as WebSearchConfig; +} + +function resolveFetchConfig(cfg?: OpenClawConfig): WebFetchConfig { + const fetch = cfg?.tools?.web?.fetch; + if (!fetch || typeof fetch !== "object") { + return undefined; + } + return fetch as WebFetchConfig; +} + +export function resolveFirecrawlSearchConfig(cfg?: OpenClawConfig): FirecrawlSearchConfig { + const search = resolveSearchConfig(cfg); + if (!search || typeof search !== "object") { + return undefined; + } + const firecrawl = "firecrawl" in search ? search.firecrawl : undefined; + if (!firecrawl || typeof firecrawl !== "object") { + return undefined; + } + return firecrawl as FirecrawlSearchConfig; +} + +export function resolveFirecrawlFetchConfig(cfg?: OpenClawConfig): FirecrawlFetchConfig { + const fetch = resolveFetchConfig(cfg); + if (!fetch || typeof fetch !== "object") { + return undefined; + } + const firecrawl = "firecrawl" in fetch ? fetch.firecrawl : undefined; + if (!firecrawl || typeof firecrawl !== "object") { + return undefined; + } + return firecrawl as FirecrawlFetchConfig; +} + +function normalizeConfiguredSecret(value: unknown, path: string): string | undefined { + return normalizeSecretInput( + normalizeResolvedSecretInputString({ + value, + path, + }), + ); +} + +export function resolveFirecrawlApiKey(cfg?: OpenClawConfig): string | undefined { + const search = resolveFirecrawlSearchConfig(cfg); + const fetch = resolveFirecrawlFetchConfig(cfg); + return ( + normalizeConfiguredSecret(search?.apiKey, "tools.web.search.firecrawl.apiKey") || + normalizeConfiguredSecret(fetch?.apiKey, "tools.web.fetch.firecrawl.apiKey") || + normalizeSecretInput(process.env.FIRECRAWL_API_KEY) || + undefined + ); +} + +export function resolveFirecrawlBaseUrl(cfg?: OpenClawConfig): string { + const search = resolveFirecrawlSearchConfig(cfg); + const fetch = resolveFirecrawlFetchConfig(cfg); + const configured = + (typeof search?.baseUrl === "string" ? search.baseUrl.trim() : "") || + (typeof fetch?.baseUrl === "string" ? fetch.baseUrl.trim() : "") || + normalizeSecretInput(process.env.FIRECRAWL_BASE_URL) || + ""; + return configured || DEFAULT_FIRECRAWL_BASE_URL; +} + +export function resolveFirecrawlOnlyMainContent(cfg?: OpenClawConfig, override?: boolean): boolean { + if (typeof override === "boolean") { + return override; + } + const fetch = resolveFirecrawlFetchConfig(cfg); + if (typeof fetch?.onlyMainContent === "boolean") { + return fetch.onlyMainContent; + } + return true; +} + +export function resolveFirecrawlMaxAgeMs(cfg?: OpenClawConfig, override?: number): number { + if (typeof override === "number" && Number.isFinite(override) && override >= 0) { + return Math.floor(override); + } + const fetch = resolveFirecrawlFetchConfig(cfg); + if ( + typeof fetch?.maxAgeMs === "number" && + Number.isFinite(fetch.maxAgeMs) && + fetch.maxAgeMs >= 0 + ) { + return Math.floor(fetch.maxAgeMs); + } + return DEFAULT_FIRECRAWL_MAX_AGE_MS; +} + +export function resolveFirecrawlScrapeTimeoutSeconds( + cfg?: OpenClawConfig, + override?: number, +): number { + if (typeof override === "number" && Number.isFinite(override) && override > 0) { + return Math.floor(override); + } + const fetch = resolveFirecrawlFetchConfig(cfg); + if ( + typeof fetch?.timeoutSeconds === "number" && + Number.isFinite(fetch.timeoutSeconds) && + fetch.timeoutSeconds > 0 + ) { + return Math.floor(fetch.timeoutSeconds); + } + return DEFAULT_FIRECRAWL_SCRAPE_TIMEOUT_SECONDS; +} + +export function resolveFirecrawlSearchTimeoutSeconds(override?: number): number { + if (typeof override === "number" && Number.isFinite(override) && override > 0) { + return Math.floor(override); + } + return DEFAULT_FIRECRAWL_SEARCH_TIMEOUT_SECONDS; +} diff --git a/extensions/firecrawl/src/firecrawl-client.ts b/extensions/firecrawl/src/firecrawl-client.ts new file mode 100644 index 00000000000..2929f2f9dde --- /dev/null +++ b/extensions/firecrawl/src/firecrawl-client.ts @@ -0,0 +1,446 @@ +import { markdownToText, truncateText } from "../../../src/agents/tools/web-fetch-utils.js"; +import { withTrustedWebToolsEndpoint } from "../../../src/agents/tools/web-guarded-fetch.js"; +import { + DEFAULT_CACHE_TTL_MINUTES, + normalizeCacheKey, + readCache, + readResponseText, + resolveCacheTtlMs, + writeCache, +} from "../../../src/agents/tools/web-shared.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { wrapExternalContent, wrapWebContent } from "../../../src/security/external-content.js"; +import { + resolveFirecrawlApiKey, + resolveFirecrawlBaseUrl, + resolveFirecrawlMaxAgeMs, + resolveFirecrawlOnlyMainContent, + resolveFirecrawlScrapeTimeoutSeconds, + resolveFirecrawlSearchTimeoutSeconds, +} from "./config.js"; + +const SEARCH_CACHE = new Map< + string, + { value: Record; expiresAt: number; insertedAt: number } +>(); +const SCRAPE_CACHE = new Map< + string, + { value: Record; expiresAt: number; insertedAt: number } +>(); +const DEFAULT_SEARCH_COUNT = 5; +const DEFAULT_SCRAPE_MAX_CHARS = 50_000; +const DEFAULT_ERROR_MAX_BYTES = 64_000; + +type FirecrawlSearchItem = { + title: string; + url: string; + description?: string; + content?: string; + published?: string; + siteName?: string; +}; + +export type FirecrawlSearchParams = { + cfg?: OpenClawConfig; + query: string; + count?: number; + timeoutSeconds?: number; + sources?: string[]; + categories?: string[]; + scrapeResults?: boolean; +}; + +export type FirecrawlScrapeParams = { + cfg?: OpenClawConfig; + url: string; + extractMode: "markdown" | "text"; + maxChars?: number; + onlyMainContent?: boolean; + maxAgeMs?: number; + proxy?: "auto" | "basic" | "stealth"; + storeInCache?: boolean; + timeoutSeconds?: number; +}; + +function resolveEndpoint(baseUrl: string, pathname: "/v2/search" | "/v2/scrape"): string { + const trimmed = baseUrl.trim(); + if (!trimmed) { + return new URL(pathname, "https://api.firecrawl.dev").toString(); + } + try { + const url = new URL(trimmed); + if (url.pathname && url.pathname !== "/") { + return url.toString(); + } + url.pathname = pathname; + return url.toString(); + } catch { + return new URL(pathname, "https://api.firecrawl.dev").toString(); + } +} + +function resolveSiteName(urlRaw: string): string | undefined { + try { + const host = new URL(urlRaw).hostname.replace(/^www\./, ""); + return host || undefined; + } catch { + return undefined; + } +} + +async function postFirecrawlJson(params: { + baseUrl: string; + pathname: "/v2/search" | "/v2/scrape"; + apiKey: string; + body: Record; + timeoutSeconds: number; + errorLabel: string; +}): Promise> { + const endpoint = resolveEndpoint(params.baseUrl, params.pathname); + return await withTrustedWebToolsEndpoint( + { + url: endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + Accept: "application/json", + Authorization: `Bearer ${params.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(params.body), + }, + }, + async ({ response }) => { + if (!response.ok) { + const detail = await readResponseText(response, { maxBytes: DEFAULT_ERROR_MAX_BYTES }); + throw new Error( + `${params.errorLabel} API error (${response.status}): ${detail.text || response.statusText}`, + ); + } + const payload = (await response.json()) as Record; + if (payload.success === false) { + const error = + typeof payload.error === "string" + ? payload.error + : typeof payload.message === "string" + ? payload.message + : "unknown error"; + throw new Error(`${params.errorLabel} API error: ${error}`); + } + return payload; + }, + ); +} + +function resolveSearchItems(payload: Record): FirecrawlSearchItem[] { + const candidates = [ + payload.data, + payload.results, + (payload.data as { results?: unknown } | undefined)?.results, + (payload.data as { data?: unknown } | undefined)?.data, + (payload.data as { web?: unknown } | undefined)?.web, + (payload.web as { results?: unknown } | undefined)?.results, + ]; + const rawItems = candidates.find((candidate) => Array.isArray(candidate)); + if (!Array.isArray(rawItems)) { + return []; + } + const items: FirecrawlSearchItem[] = []; + for (const entry of rawItems) { + if (!entry || typeof entry !== "object") { + continue; + } + const record = entry as Record; + const metadata = + record.metadata && typeof record.metadata === "object" + ? (record.metadata as Record) + : undefined; + const url = + (typeof record.url === "string" && record.url) || + (typeof record.sourceURL === "string" && record.sourceURL) || + (typeof record.sourceUrl === "string" && record.sourceUrl) || + (typeof metadata?.sourceURL === "string" && metadata.sourceURL) || + ""; + if (!url) { + continue; + } + const title = + (typeof record.title === "string" && record.title) || + (typeof metadata?.title === "string" && metadata.title) || + ""; + const description = + (typeof record.description === "string" && record.description) || + (typeof record.snippet === "string" && record.snippet) || + (typeof record.summary === "string" && record.summary) || + undefined; + const content = + (typeof record.markdown === "string" && record.markdown) || + (typeof record.content === "string" && record.content) || + (typeof record.text === "string" && record.text) || + undefined; + const published = + (typeof record.publishedDate === "string" && record.publishedDate) || + (typeof record.published === "string" && record.published) || + (typeof metadata?.publishedTime === "string" && metadata.publishedTime) || + (typeof metadata?.publishedDate === "string" && metadata.publishedDate) || + undefined; + items.push({ + title, + url, + description, + content, + published, + siteName: resolveSiteName(url), + }); + } + return items; +} + +function buildSearchPayload(params: { + query: string; + provider: "firecrawl"; + items: FirecrawlSearchItem[]; + tookMs: number; + scrapeResults: boolean; +}): Record { + return { + query: params.query, + provider: params.provider, + count: params.items.length, + tookMs: params.tookMs, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + results: params.items.map((entry) => ({ + title: entry.title ? wrapWebContent(entry.title, "web_search") : "", + url: entry.url, + description: entry.description ? wrapWebContent(entry.description, "web_search") : "", + ...(entry.published ? { published: entry.published } : {}), + ...(entry.siteName ? { siteName: entry.siteName } : {}), + ...(params.scrapeResults && entry.content + ? { content: wrapWebContent(entry.content, "web_search") } + : {}), + })), + }; +} + +export async function runFirecrawlSearch( + params: FirecrawlSearchParams, +): Promise> { + const apiKey = resolveFirecrawlApiKey(params.cfg); + if (!apiKey) { + throw new Error( + "web_search (firecrawl) needs a Firecrawl API key. Set FIRECRAWL_API_KEY in the Gateway environment, or configure tools.web.search.firecrawl.apiKey.", + ); + } + const count = + typeof params.count === "number" && Number.isFinite(params.count) + ? Math.max(1, Math.min(10, Math.floor(params.count))) + : DEFAULT_SEARCH_COUNT; + const timeoutSeconds = resolveFirecrawlSearchTimeoutSeconds(params.timeoutSeconds); + const scrapeResults = params.scrapeResults === true; + const sources = Array.isArray(params.sources) ? params.sources.filter(Boolean) : []; + const categories = Array.isArray(params.categories) ? params.categories.filter(Boolean) : []; + const baseUrl = resolveFirecrawlBaseUrl(params.cfg); + const cacheKey = normalizeCacheKey( + JSON.stringify({ + type: "firecrawl-search", + q: params.query, + count, + baseUrl, + sources, + categories, + scrapeResults, + }), + ); + const cached = readCache(SEARCH_CACHE, cacheKey); + if (cached) { + return { ...cached.value, cached: true }; + } + + const body: Record = { + query: params.query, + limit: count, + }; + if (sources.length > 0) { + body.sources = sources; + } + if (categories.length > 0) { + body.categories = categories; + } + if (scrapeResults) { + body.scrapeOptions = { + formats: ["markdown"], + }; + } + + const start = Date.now(); + const payload = await postFirecrawlJson({ + baseUrl, + pathname: "/v2/search", + apiKey, + body, + timeoutSeconds, + errorLabel: "Firecrawl Search", + }); + const result = buildSearchPayload({ + query: params.query, + provider: "firecrawl", + items: resolveSearchItems(payload), + tookMs: Date.now() - start, + scrapeResults, + }); + writeCache( + SEARCH_CACHE, + cacheKey, + result, + resolveCacheTtlMs(undefined, DEFAULT_CACHE_TTL_MINUTES), + ); + return result; +} + +function resolveScrapeData(payload: Record): Record { + const data = payload.data; + if (data && typeof data === "object") { + return data as Record; + } + return {}; +} + +export function parseFirecrawlScrapePayload(params: { + payload: Record; + url: string; + extractMode: "markdown" | "text"; + maxChars: number; +}): Record { + const data = resolveScrapeData(params.payload); + const metadata = + data.metadata && typeof data.metadata === "object" + ? (data.metadata as Record) + : undefined; + const markdown = + (typeof data.markdown === "string" && data.markdown) || + (typeof data.content === "string" && data.content) || + ""; + if (!markdown) { + throw new Error("Firecrawl scrape returned no content."); + } + const rawText = params.extractMode === "text" ? markdownToText(markdown) : markdown; + const truncated = truncateText(rawText, params.maxChars); + return { + url: params.url, + finalUrl: + (typeof metadata?.sourceURL === "string" && metadata.sourceURL) || + (typeof data.url === "string" && data.url) || + params.url, + status: + (typeof metadata?.statusCode === "number" && metadata.statusCode) || + (typeof data.statusCode === "number" && data.statusCode) || + undefined, + title: + typeof metadata?.title === "string" && metadata.title + ? wrapExternalContent(metadata.title, { source: "web_fetch", includeWarning: false }) + : undefined, + extractor: "firecrawl", + extractMode: params.extractMode, + externalContent: { + untrusted: true, + source: "web_fetch", + wrapped: true, + }, + truncated: truncated.truncated, + rawLength: rawText.length, + wrappedLength: wrapExternalContent(truncated.text, { + source: "web_fetch", + includeWarning: false, + }).length, + text: wrapExternalContent(truncated.text, { + source: "web_fetch", + includeWarning: false, + }), + warning: + typeof params.payload.warning === "string" && params.payload.warning + ? wrapExternalContent(params.payload.warning, { + source: "web_fetch", + includeWarning: false, + }) + : undefined, + }; +} + +export async function runFirecrawlScrape( + params: FirecrawlScrapeParams, +): Promise> { + const apiKey = resolveFirecrawlApiKey(params.cfg); + if (!apiKey) { + throw new Error( + "firecrawl_scrape needs a Firecrawl API key. Set FIRECRAWL_API_KEY in the Gateway environment, or configure tools.web.fetch.firecrawl.apiKey.", + ); + } + const baseUrl = resolveFirecrawlBaseUrl(params.cfg); + const timeoutSeconds = resolveFirecrawlScrapeTimeoutSeconds(params.cfg, params.timeoutSeconds); + const onlyMainContent = resolveFirecrawlOnlyMainContent(params.cfg, params.onlyMainContent); + const maxAgeMs = resolveFirecrawlMaxAgeMs(params.cfg, params.maxAgeMs); + const proxy = params.proxy ?? "auto"; + const storeInCache = params.storeInCache ?? true; + const maxChars = + typeof params.maxChars === "number" && Number.isFinite(params.maxChars) && params.maxChars > 0 + ? Math.floor(params.maxChars) + : DEFAULT_SCRAPE_MAX_CHARS; + const cacheKey = normalizeCacheKey( + JSON.stringify({ + type: "firecrawl-scrape", + url: params.url, + extractMode: params.extractMode, + baseUrl, + onlyMainContent, + maxAgeMs, + proxy, + storeInCache, + maxChars, + }), + ); + const cached = readCache(SCRAPE_CACHE, cacheKey); + if (cached) { + return { ...cached.value, cached: true }; + } + + const payload = await postFirecrawlJson({ + baseUrl, + pathname: "/v2/scrape", + apiKey, + timeoutSeconds, + errorLabel: "Firecrawl", + body: { + url: params.url, + formats: ["markdown"], + onlyMainContent, + timeout: timeoutSeconds * 1000, + maxAge: maxAgeMs, + proxy, + storeInCache, + }, + }); + const result = parseFirecrawlScrapePayload({ + payload, + url: params.url, + extractMode: params.extractMode, + maxChars, + }); + writeCache( + SCRAPE_CACHE, + cacheKey, + result, + resolveCacheTtlMs(undefined, DEFAULT_CACHE_TTL_MINUTES), + ); + return result; +} + +export const __testing = { + parseFirecrawlScrapePayload, + resolveSearchItems, +}; diff --git a/extensions/firecrawl/src/firecrawl-scrape-tool.ts b/extensions/firecrawl/src/firecrawl-scrape-tool.ts new file mode 100644 index 00000000000..509b3d5fbd6 --- /dev/null +++ b/extensions/firecrawl/src/firecrawl-scrape-tool.ts @@ -0,0 +1,89 @@ +import { Type } from "@sinclair/typebox"; +import { optionalStringEnum } from "../../../src/agents/schema/typebox.js"; +import { jsonResult, readNumberParam, readStringParam } from "../../../src/agents/tools/common.js"; +import type { OpenClawPluginApi } from "../../../src/plugins/types.js"; +import { runFirecrawlScrape } from "./firecrawl-client.js"; + +const FirecrawlScrapeToolSchema = Type.Object( + { + url: Type.String({ description: "HTTP or HTTPS URL to scrape via Firecrawl." }), + extractMode: optionalStringEnum(["markdown", "text"] as const, { + description: 'Extraction mode ("markdown" or "text"). Default: markdown.', + }), + maxChars: Type.Optional( + Type.Number({ + description: "Maximum characters to return.", + minimum: 100, + }), + ), + onlyMainContent: Type.Optional( + Type.Boolean({ + description: "Keep only main content when Firecrawl supports it.", + }), + ), + maxAgeMs: Type.Optional( + Type.Number({ + description: "Maximum Firecrawl cache age in milliseconds.", + minimum: 0, + }), + ), + proxy: optionalStringEnum(["auto", "basic", "stealth"] as const, { + description: 'Firecrawl proxy mode ("auto", "basic", or "stealth").', + }), + storeInCache: Type.Optional( + Type.Boolean({ + description: "Whether Firecrawl should store the scrape in its cache.", + }), + ), + timeoutSeconds: Type.Optional( + Type.Number({ + description: "Timeout in seconds for the Firecrawl scrape request.", + minimum: 1, + }), + ), + }, + { additionalProperties: false }, +); + +export function createFirecrawlScrapeTool(api: OpenClawPluginApi) { + return { + name: "firecrawl_scrape", + label: "Firecrawl Scrape", + description: + "Scrape a page using Firecrawl v2/scrape. Useful for JS-heavy or bot-protected pages where plain web_fetch is weak.", + parameters: FirecrawlScrapeToolSchema, + execute: async (_toolCallId: string, rawParams: Record) => { + const url = readStringParam(rawParams, "url", { required: true }); + const extractMode = + readStringParam(rawParams, "extractMode") === "text" ? "text" : "markdown"; + const maxChars = readNumberParam(rawParams, "maxChars", { integer: true }); + const maxAgeMs = readNumberParam(rawParams, "maxAgeMs", { integer: true }); + const timeoutSeconds = readNumberParam(rawParams, "timeoutSeconds", { + integer: true, + }); + const proxyRaw = readStringParam(rawParams, "proxy"); + const proxy = + proxyRaw === "basic" || proxyRaw === "stealth" || proxyRaw === "auto" + ? proxyRaw + : undefined; + const onlyMainContent = + typeof rawParams.onlyMainContent === "boolean" ? rawParams.onlyMainContent : undefined; + const storeInCache = + typeof rawParams.storeInCache === "boolean" ? rawParams.storeInCache : undefined; + + return jsonResult( + await runFirecrawlScrape({ + cfg: api.config, + url, + extractMode, + maxChars, + onlyMainContent, + maxAgeMs, + proxy, + storeInCache, + timeoutSeconds, + }), + ); + }, + }; +} diff --git a/extensions/firecrawl/src/firecrawl-search-provider.ts b/extensions/firecrawl/src/firecrawl-search-provider.ts new file mode 100644 index 00000000000..60489e9618e --- /dev/null +++ b/extensions/firecrawl/src/firecrawl-search-provider.ts @@ -0,0 +1,63 @@ +import { Type } from "@sinclair/typebox"; +import type { WebSearchProviderPlugin } from "../../../src/plugins/types.js"; +import { runFirecrawlSearch } from "./firecrawl-client.js"; + +const GenericFirecrawlSearchSchema = Type.Object( + { + query: Type.String({ description: "Search query string." }), + count: Type.Optional( + Type.Number({ + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, + }), + ), + }, + { additionalProperties: false }, +); + +function getScopedCredentialValue(searchConfig?: Record): unknown { + const scoped = searchConfig?.firecrawl; + if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { + return undefined; + } + return (scoped as Record).apiKey; +} + +function setScopedCredentialValue( + searchConfigTarget: Record, + value: unknown, +): void { + const scoped = searchConfigTarget.firecrawl; + if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { + searchConfigTarget.firecrawl = { apiKey: value }; + return; + } + (scoped as Record).apiKey = value; +} + +export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin { + return { + id: "firecrawl", + label: "Firecrawl Search", + hint: "Structured results with optional result scraping", + envVars: ["FIRECRAWL_API_KEY"], + placeholder: "fc-...", + signupUrl: "https://www.firecrawl.dev/", + docsUrl: "https://docs.openclaw.ai/tools/firecrawl", + autoDetectOrder: 60, + getCredentialValue: getScopedCredentialValue, + setCredentialValue: setScopedCredentialValue, + createTool: (ctx) => ({ + description: + "Search the web using Firecrawl. Returns structured results with snippets from Firecrawl Search. Use firecrawl_search for Firecrawl-specific knobs like sources or categories.", + parameters: GenericFirecrawlSearchSchema, + execute: async (args) => + await runFirecrawlSearch({ + cfg: ctx.config, + query: typeof args.query === "string" ? args.query : "", + count: typeof args.count === "number" ? args.count : undefined, + }), + }), + }; +} diff --git a/extensions/firecrawl/src/firecrawl-search-tool.ts b/extensions/firecrawl/src/firecrawl-search-tool.ts new file mode 100644 index 00000000000..f2f133fd7ec --- /dev/null +++ b/extensions/firecrawl/src/firecrawl-search-tool.ts @@ -0,0 +1,76 @@ +import { Type } from "@sinclair/typebox"; +import { + jsonResult, + readNumberParam, + readStringArrayParam, + readStringParam, +} from "../../../src/agents/tools/common.js"; +import type { OpenClawPluginApi } from "../../../src/plugins/types.js"; +import { runFirecrawlSearch } from "./firecrawl-client.js"; + +const FirecrawlSearchToolSchema = Type.Object( + { + query: Type.String({ description: "Search query string." }), + count: Type.Optional( + Type.Number({ + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, + }), + ), + sources: Type.Optional( + Type.Array(Type.String(), { + description: 'Optional sources list, for example ["web"], ["news"], or ["images"].', + }), + ), + categories: Type.Optional( + Type.Array(Type.String(), { + description: 'Optional Firecrawl categories, for example ["github"] or ["research"].', + }), + ), + scrapeResults: Type.Optional( + Type.Boolean({ + description: "Include scraped result content when Firecrawl returns it.", + }), + ), + timeoutSeconds: Type.Optional( + Type.Number({ + description: "Timeout in seconds for the Firecrawl Search request.", + minimum: 1, + }), + ), + }, + { additionalProperties: false }, +); + +export function createFirecrawlSearchTool(api: OpenClawPluginApi) { + return { + name: "firecrawl_search", + label: "Firecrawl Search", + description: + "Search the web using Firecrawl v2/search. Can optionally include scraped content from result pages.", + parameters: FirecrawlSearchToolSchema, + execute: async (_toolCallId: string, rawParams: Record) => { + const query = readStringParam(rawParams, "query", { required: true }); + const count = readNumberParam(rawParams, "count", { integer: true }); + const timeoutSeconds = readNumberParam(rawParams, "timeoutSeconds", { + integer: true, + }); + const sources = readStringArrayParam(rawParams, "sources"); + const categories = readStringArrayParam(rawParams, "categories"); + const scrapeResults = rawParams.scrapeResults === true; + + return jsonResult( + await runFirecrawlSearch({ + cfg: api.config, + query, + count, + timeoutSeconds, + sources, + categories, + scrapeResults, + }), + ); + }, + }; +} diff --git a/src/agents/tools/web-fetch-utils.ts b/src/agents/tools/web-fetch-utils.ts index 4dc57abf80d..86d03650eb6 100644 --- a/src/agents/tools/web-fetch-utils.ts +++ b/src/agents/tools/web-fetch-utils.ts @@ -206,27 +206,33 @@ function exceedsEstimatedHtmlNestingDepth(html: string, maxDepth: number): boole return false; } +export async function extractBasicHtmlContent(params: { + html: string; + extractMode: ExtractMode; +}): Promise<{ text: string; title?: string } | null> { + const cleanHtml = await sanitizeHtml(params.html); + const rendered = htmlToMarkdown(cleanHtml); + if (params.extractMode === "text") { + const text = + stripInvisibleUnicode(markdownToText(rendered.text)) || + stripInvisibleUnicode(normalizeWhitespace(stripTags(cleanHtml))); + return text ? { text, title: rendered.title } : null; + } + const text = stripInvisibleUnicode(rendered.text); + return text ? { text, title: rendered.title } : null; +} + export async function extractReadableContent(params: { html: string; url: string; extractMode: ExtractMode; }): Promise<{ text: string; title?: string } | null> { const cleanHtml = await sanitizeHtml(params.html); - const fallback = (): { text: string; title?: string } => { - const rendered = htmlToMarkdown(cleanHtml); - if (params.extractMode === "text") { - const text = - stripInvisibleUnicode(markdownToText(rendered.text)) || - stripInvisibleUnicode(normalizeWhitespace(stripTags(cleanHtml))); - return { text, title: rendered.title }; - } - return { text: stripInvisibleUnicode(rendered.text), title: rendered.title }; - }; if ( cleanHtml.length > READABILITY_MAX_HTML_CHARS || exceedsEstimatedHtmlNestingDepth(cleanHtml, READABILITY_MAX_ESTIMATED_NESTING_DEPTH) ) { - return fallback(); + return null; } try { const { Readability, parseHTML } = await loadReadabilityDeps(); @@ -239,16 +245,17 @@ export async function extractReadableContent(params: { const reader = new Readability(document, { charThreshold: 0 }); const parsed = reader.parse(); if (!parsed?.content) { - return fallback(); + return null; } const title = parsed.title || undefined; if (params.extractMode === "text") { const text = stripInvisibleUnicode(normalizeWhitespace(parsed.textContent ?? "")); - return text ? { text, title } : fallback(); + return text ? { text, title } : null; } const rendered = htmlToMarkdown(parsed.content); - return { text: stripInvisibleUnicode(rendered.text), title: title ?? rendered.title }; + const text = stripInvisibleUnicode(rendered.text); + return text ? { text, title: title ?? rendered.title } : null; } catch { - return fallback(); + return null; } } diff --git a/src/agents/tools/web-fetch.ts b/src/agents/tools/web-fetch.ts index f4cc88e2d83..92f94bf3a28 100644 --- a/src/agents/tools/web-fetch.ts +++ b/src/agents/tools/web-fetch.ts @@ -10,13 +10,14 @@ import { stringEnum } from "../schema/typebox.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js"; import { + extractBasicHtmlContent, extractReadableContent, htmlToMarkdown, markdownToText, truncateText, type ExtractMode, } from "./web-fetch-utils.js"; -import { fetchWithWebToolsNetworkGuard } from "./web-guarded-fetch.js"; +import { fetchWithWebToolsNetworkGuard, withTrustedWebToolsEndpoint } from "./web-guarded-fetch.js"; import { CacheEntry, DEFAULT_CACHE_TTL_MINUTES, @@ -26,7 +27,6 @@ import { readResponseText, resolveCacheTtlMs, resolveTimeoutSeconds, - withTimeout, writeCache, } from "./web-shared.js"; @@ -161,11 +161,12 @@ function resolveFirecrawlEnabled(params: { } function resolveFirecrawlBaseUrl(firecrawl?: FirecrawlFetchConfig): string { - const raw = + const fromConfig = firecrawl && "baseUrl" in firecrawl && typeof firecrawl.baseUrl === "string" ? firecrawl.baseUrl.trim() : ""; - return raw || DEFAULT_FIRECRAWL_BASE_URL; + const fromEnv = normalizeSecretInput(process.env.FIRECRAWL_BASE_URL); + return fromConfig || fromEnv || DEFAULT_FIRECRAWL_BASE_URL; } function resolveFirecrawlOnlyMainContent(firecrawl?: FirecrawlFetchConfig): boolean { @@ -381,54 +382,59 @@ export async function fetchFirecrawlContent(params: { proxy: params.proxy, storeInCache: params.storeInCache, }; - - const res = await fetch(endpoint, { - method: "POST", - headers: { - Authorization: `Bearer ${params.apiKey}`, - "Content-Type": "application/json", + return await withTrustedWebToolsEndpoint( + { + url: endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + Authorization: `Bearer ${params.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }, }, - body: JSON.stringify(body), - signal: withTimeout(undefined, params.timeoutSeconds * 1000), - }); - - const payload = (await res.json()) as { - success?: boolean; - data?: { - markdown?: string; - content?: string; - metadata?: { - title?: string; - sourceURL?: string; - statusCode?: number; + async ({ response }) => { + const payload = (await response.json()) as { + success?: boolean; + data?: { + markdown?: string; + content?: string; + metadata?: { + title?: string; + sourceURL?: string; + statusCode?: number; + }; + }; + warning?: string; + error?: string; }; - }; - warning?: string; - error?: string; - }; - if (!res.ok || payload?.success === false) { - const detail = payload?.error ?? ""; - throw new Error( - `Firecrawl fetch failed (${res.status}): ${wrapWebContent(detail || res.statusText, "web_fetch")}`.trim(), - ); - } + if (!response.ok || payload?.success === false) { + const detail = payload?.error ?? ""; + throw new Error( + `Firecrawl fetch failed (${response.status}): ${wrapWebContent(detail || response.statusText, "web_fetch")}`.trim(), + ); + } - const data = payload?.data ?? {}; - const rawText = - typeof data.markdown === "string" - ? data.markdown - : typeof data.content === "string" - ? data.content - : ""; - const text = params.extractMode === "text" ? markdownToText(rawText) : rawText; - return { - text, - title: data.metadata?.title, - finalUrl: data.metadata?.sourceURL, - status: data.metadata?.statusCode, - warning: payload?.warning, - }; + const data = payload?.data ?? {}; + const rawText = + typeof data.markdown === "string" + ? data.markdown + : typeof data.content === "string" + ? data.content + : ""; + const text = params.extractMode === "text" ? markdownToText(rawText) : rawText; + return { + text, + title: data.metadata?.title, + finalUrl: data.metadata?.sourceURL, + status: data.metadata?.statusCode, + warning: payload?.warning, + }; + }, + ); } type FirecrawlRuntimeParams = { @@ -629,9 +635,19 @@ async function runWebFetch(params: WebFetchRuntimeParams): Promise { expect(authHeader).toBe("Bearer firecrawl-test-key"); }); + it("uses FIRECRAWL_BASE_URL env var when firecrawl.baseUrl is unset", async () => { + vi.stubEnv("FIRECRAWL_BASE_URL", "https://fc.example.com"); + + expect(webFetchTesting.resolveFirecrawlBaseUrl({})).toBe("https://fc.example.com"); + }); + + it("uses guarded endpoint fetch for firecrawl requests", async () => { + vi.stubEnv("HTTP_PROXY", "http://127.0.0.1:7890"); + + const fetchSpy = installMockFetch((input: RequestInfo | URL) => { + const url = resolveRequestUrl(input); + if (url.includes("api.firecrawl.dev/v2/scrape")) { + return Promise.resolve( + firecrawlResponse("firecrawl guarded transport"), + ) as Promise; + } + return Promise.resolve( + htmlResponse("", url), + ) as Promise; + }); + + const tool = createFirecrawlTool(); + const result = await executeFetch(tool, { url: "https://example.com/guarded-firecrawl" }); + + expect(result?.details).toMatchObject({ extractor: "firecrawl" }); + const firecrawlCall = fetchSpy.mock.calls.find((call) => + resolveRequestUrl(call[0]).includes("/v2/scrape"), + ); + expect(firecrawlCall).toBeTruthy(); + const requestInit = firecrawlCall?.[1] as (RequestInit & { dispatcher?: unknown }) | undefined; + expect(requestInit?.dispatcher).toBeDefined(); + expect(requestInit?.dispatcher).toBeInstanceOf(EnvHttpProxyAgent); + }); + it("throws when readability is disabled and firecrawl is unavailable", async () => { installMockFetch( (input: RequestInfo | URL) => @@ -356,7 +391,29 @@ describe("web_fetch extraction fallbacks", () => { const tool = createFirecrawlTool(); await expect( executeFetch(tool, { url: "https://example.com/readability-empty" }), - ).rejects.toThrow("Readability and Firecrawl returned no content"); + ).rejects.toThrow("Readability, Firecrawl, and basic HTML cleanup returned no content"); + }); + + it("falls back to basic HTML cleanup after readability and before giving up", async () => { + installMockFetch( + (input: RequestInfo | URL) => + Promise.resolve( + htmlResponse( + "Shell App
", + resolveRequestUrl(input), + ), + ) as Promise, + ); + + const tool = createFetchTool({ + firecrawl: { enabled: false }, + }); + const result = await executeFetch(tool, { url: "https://example.com/shell" }); + const details = result?.details as { extractor?: string; text?: string; title?: string }; + + expect(details.extractor).toBe("raw-html"); + expect(details.text).toContain("Shell App"); + expect(details.title).toContain("Shell App"); }); it("uses firecrawl when direct fetch fails", async () => { diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index 93451a9d6e9..00bfd6382a6 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -116,6 +116,19 @@ describe("setupSearch", () => { expect(result.tools?.web?.search?.gemini?.apiKey).toBe("AIza-test"); }); + it("sets provider and key for firecrawl and enables the plugin", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ + selectValue: "firecrawl", + textValue: "fc-test-key", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.provider).toBe("firecrawl"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(result.tools?.web?.search?.firecrawl?.apiKey).toBe("fc-test-key"); + expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true); + }); + it("sets provider and key for grok", async () => { const cfg: OpenClawConfig = {}; const { prompter } = createPrompter({ @@ -331,9 +344,9 @@ describe("setupSearch", () => { expect(result.tools?.web?.search?.apiKey).toBe("BSA-plain"); }); - it("exports all 5 providers in SEARCH_PROVIDER_OPTIONS", () => { - expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(5); + it("exports all 6 providers in SEARCH_PROVIDER_OPTIONS", () => { + expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(6); const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.value); - expect(values).toEqual(["brave", "gemini", "grok", "kimi", "perplexity"]); + expect(values).toEqual(["brave", "gemini", "grok", "kimi", "perplexity", "firecrawl"]); }); }); diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index af5f3cd9a8f..72fafe461d2 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -6,6 +6,7 @@ import { hasConfiguredSecretInput, normalizeSecretInputString, } from "../config/types.secrets.js"; +import { enablePluginInConfig } from "../plugins/enable.js"; import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; @@ -15,7 +16,7 @@ export type SearchProvider = NonNullable< NonNullable["web"]>["search"]>["provider"] >; -const SEARCH_PROVIDER_IDS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const; +const SEARCH_PROVIDER_IDS = ["brave", "firecrawl", "gemini", "grok", "kimi", "perplexity"] as const; function isSearchProvider(value: string): value is SearchProvider { return (SEARCH_PROVIDER_IDS as readonly string[]).includes(value); @@ -114,17 +115,21 @@ export function applySearchKey( if (entry) { entry.setCredentialValue(search as Record, key); } - return { + const next = { ...config, tools: { ...config.tools, web: { ...config.tools?.web, search }, }, }; + if (provider !== "firecrawl") { + return next; + } + return enablePluginInConfig(next, "firecrawl").config; } function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): OpenClawConfig { - return { + const next = { ...config, tools: { ...config.tools, @@ -138,6 +143,10 @@ function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): Op }, }, }; + if (provider !== "firecrawl") { + return next; + } + return enablePluginInConfig(next, "firecrawl").config; } function preserveDisabledState(original: OpenClawConfig, result: OpenClawConfig): OpenClawConfig { diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index 9df692962f2..912e70ac5a4 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -16,6 +16,11 @@ vi.mock("../plugins/web-search-providers.js", () => { envVars: ["BRAVE_API_KEY"], getCredentialValue: (search?: Record) => search?.apiKey, }, + { + id: "firecrawl", + envVars: ["FIRECRAWL_API_KEY"], + getCredentialValue: getScoped("firecrawl"), + }, { id: "gemini", envVars: ["GEMINI_API_KEY"], @@ -75,6 +80,21 @@ describe("web search provider config", () => { expect(res.ok).toBe(true); }); + it("accepts firecrawl provider and config", () => { + const res = validateConfigObject( + buildWebSearchProviderConfig({ + enabled: true, + provider: "firecrawl", + providerConfig: { + apiKey: "fc-test-key", // pragma: allowlist secret + baseUrl: "https://api.firecrawl.dev", + }, + }), + ); + + expect(res.ok).toBe(true); + }); + it("accepts gemini provider with no extra config", () => { const res = validateConfigObject( buildWebSearchProviderConfig({ @@ -117,6 +137,7 @@ describe("web search provider auto-detection", () => { beforeEach(() => { delete process.env.BRAVE_API_KEY; + delete process.env.FIRECRAWL_API_KEY; delete process.env.GEMINI_API_KEY; delete process.env.KIMI_API_KEY; delete process.env.MOONSHOT_API_KEY; @@ -146,6 +167,11 @@ describe("web search provider auto-detection", () => { expect(resolveSearchProvider({})).toBe("gemini"); }); + it("auto-detects firecrawl when only FIRECRAWL_API_KEY is set", () => { + process.env.FIRECRAWL_API_KEY = "fc-test-key"; // pragma: allowlist secret + expect(resolveSearchProvider({})).toBe("firecrawl"); + }); + it("auto-detects kimi when only KIMI_API_KEY is set", () => { process.env.KIMI_API_KEY = "test-kimi-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("kimi"); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 0d03f9574b1..e5d30070317 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -665,13 +665,17 @@ export const FIELD_HELP: Record = { "tools.message.broadcast.enabled": "Enable broadcast action (default: true).", "tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).", "tools.web.search.provider": - 'Search provider ("brave", "gemini", "grok", "kimi", or "perplexity"). Auto-detected from available API keys if omitted.', + 'Search provider ("brave", "firecrawl", "gemini", "grok", "kimi", or "perplexity"). Auto-detected from available API keys if omitted.', "tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).", "tools.web.search.maxResults": "Number of results to return (1-10).", "tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.", "tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.", "tools.web.search.brave.mode": 'Brave Search mode: "web" (URL results) or "llm-context" (pre-extracted page content for LLM grounding).', + "tools.web.search.firecrawl.apiKey": + "Firecrawl API key for web search (fallback: FIRECRAWL_API_KEY env var).", + "tools.web.search.firecrawl.baseUrl": + 'Firecrawl Search base URL override (default: "https://api.firecrawl.dev").', "tools.web.search.gemini.apiKey": "Gemini API key for Google Search grounding (fallback: GEMINI_API_KEY env var).", "tools.web.search.gemini.model": 'Gemini model override (default: "gemini-2.5-flash").', diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index dc5195fb766..d2c0cb29e48 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -221,6 +221,8 @@ export const FIELD_LABELS: Record = { "tools.web.search.timeoutSeconds": "Web Search Timeout (sec)", "tools.web.search.cacheTtlMinutes": "Web Search Cache TTL (min)", "tools.web.search.brave.mode": "Brave Search Mode", + "tools.web.search.firecrawl.apiKey": "Firecrawl Search API Key", // pragma: allowlist secret + "tools.web.search.firecrawl.baseUrl": "Firecrawl Search Base URL", "tools.web.search.gemini.apiKey": "Gemini Search API Key", // pragma: allowlist secret "tools.web.search.gemini.model": "Gemini Search Model", "tools.web.search.grok.apiKey": "Grok Search API Key", // pragma: allowlist secret diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 43d39285b57..d1195ace393 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -457,8 +457,8 @@ export type ToolsConfig = { search?: { /** Enable web search tool (default: true when API key is present). */ enabled?: boolean; - /** Search provider ("brave", "gemini", "grok", "kimi", or "perplexity"). */ - provider?: "brave" | "gemini" | "grok" | "kimi" | "perplexity"; + /** Search provider ("brave", "firecrawl", "gemini", "grok", "kimi", or "perplexity"). */ + provider?: "brave" | "firecrawl" | "gemini" | "grok" | "kimi" | "perplexity"; /** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */ apiKey?: SecretInput; /** Default search results count (1-10). */ @@ -479,6 +479,13 @@ export type ToolsConfig = { /** Model to use for grounded search (defaults to "gemini-2.5-flash"). */ model?: string; }; + /** Firecrawl-specific configuration (used when provider="firecrawl"). */ + firecrawl?: { + /** Firecrawl API key (defaults to FIRECRAWL_API_KEY env var). */ + apiKey?: SecretInput; + /** Base URL for API requests (defaults to "https://api.firecrawl.dev"). */ + baseUrl?: string; + }; /** Grok-specific configuration (used when provider="grok"). */ grok?: { /** API key for xAI (defaults to XAI_API_KEY env var). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 2ee70e58ef6..9ddbedf929e 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -266,6 +266,7 @@ export const ToolsWebSearchSchema = z provider: z .union([ z.literal("brave"), + z.literal("firecrawl"), z.literal("perplexity"), z.literal("grok"), z.literal("gemini"), @@ -301,6 +302,13 @@ export const ToolsWebSearchSchema = z }) .strict() .optional(), + firecrawl: z + .object({ + apiKey: SecretInputSchema.optional().register(sensitive), + baseUrl: z.string().optional(), + }) + .strict() + .optional(), kimi: z .object({ apiKey: SecretInputSchema.optional().register(sensitive), diff --git a/src/plugins/web-search-providers.test.ts b/src/plugins/web-search-providers.test.ts index 2e7b79c64d2..26c9f847bf9 100644 --- a/src/plugins/web-search-providers.test.ts +++ b/src/plugins/web-search-providers.test.ts @@ -96,6 +96,7 @@ describe("resolvePluginWebSearchProviders", () => { entries: expect.objectContaining({ openrouter: { enabled: true }, brave: { enabled: true }, + firecrawl: { enabled: true }, google: { enabled: true }, moonshot: { enabled: true }, perplexity: { enabled: true }, diff --git a/src/plugins/web-search-providers.ts b/src/plugins/web-search-providers.ts index f59cf95f51a..c44bb6f2a93 100644 --- a/src/plugins/web-search-providers.ts +++ b/src/plugins/web-search-providers.ts @@ -11,6 +11,7 @@ const log = createSubsystemLogger("plugins"); const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [ "brave", + "firecrawl", "google", "moonshot", "perplexity", From ca6dbc0f0acaa0e986c14ad36ebce5a02550e469 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:39:27 -0700 Subject: [PATCH 160/943] Gateway: lazy-load SSH status helpers --- src/commands/gateway-status.ts | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index ff2ba419cc8..ecdeeaa9570 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -2,8 +2,6 @@ import { withProgress } from "../cli/progress.js"; import { readBestEffortConfig, resolveGatewayPort } from "../config/config.js"; import { probeGateway } from "../gateway/probe.js"; import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js"; -import { resolveSshConfig } from "../infra/ssh-config.js"; -import { parseSshTarget, startSshPortForward } from "../infra/ssh-tunnel.js"; import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js"; import type { RuntimeEnv } from "../runtime.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; @@ -23,6 +21,19 @@ import { sanitizeSshTarget, } from "./gateway-status/helpers.js"; +let sshConfigModulePromise: Promise | undefined; +let sshTunnelModulePromise: Promise | undefined; + +function loadSshConfigModule() { + sshConfigModulePromise ??= import("../infra/ssh-config.js"); + return sshConfigModulePromise; +} + +function loadSshTunnelModule() { + sshTunnelModulePromise ??= import("../infra/ssh-tunnel.js"); + return sshTunnelModulePromise; +} + export async function gatewayStatusCommand( opts: { url?: string; @@ -87,6 +98,7 @@ export async function gatewayStatusCommand( return null; } try { + const { startSshPortForward } = await loadSshTunnelModule(); const tunnel = await startSshPortForward({ target: sshTarget, identity: sshIdentity ?? undefined, @@ -119,11 +131,13 @@ export async function gatewayStatusCommand( const base = user ? `${user}@${host.trim()}` : host.trim(); return sshPort !== 22 ? `${base}:${sshPort}` : base; }) - .filter((candidate): candidate is string => - Boolean(candidate && parseSshTarget(candidate)), - ); - if (candidates.length > 0) { - sshTarget = candidates[0] ?? null; + .filter((candidate): candidate is string => Boolean(candidate)); + const { parseSshTarget } = await loadSshTunnelModule(); + const validCandidates = candidates.filter((candidate) => + Boolean(parseSshTarget(candidate)), + ); + if (validCandidates.length > 0) { + sshTarget = validCandidates[0] ?? null; } } @@ -420,6 +434,10 @@ async function resolveSshTarget( identity: string | null, overallTimeoutMs: number, ): Promise<{ target: string; identity?: string } | null> { + const [{ resolveSshConfig }, { parseSshTarget }] = await Promise.all([ + loadSshConfigModule(), + loadSshTunnelModule(), + ]); const parsed = parseSshTarget(rawTarget); if (!parsed) { return null; From 77d0ff629c20488e9eb0ef8ecad729e35033ac8b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:39:18 -0700 Subject: [PATCH 161/943] refactor: rename channel setup flow seam --- extensions/bluebubbles/src/setup-core.ts | 2 +- .../bluebubbles/src/setup-surface.test.ts | 6 +-- extensions/bluebubbles/src/setup-surface.ts | 10 ++-- extensions/discord/src/setup-core.ts | 10 ++-- extensions/discord/src/setup-surface.ts | 14 +++--- .../feishu/src/onboarding.status.test.ts | 4 +- extensions/feishu/src/onboarding.test.ts | 4 +- extensions/feishu/src/setup-surface.ts | 14 +++--- .../googlechat/src/setup-surface.test.ts | 4 +- extensions/googlechat/src/setup-surface.ts | 12 ++--- extensions/imessage/src/setup-core.ts | 14 +++--- extensions/imessage/src/setup-surface.ts | 12 ++--- extensions/irc/src/onboarding.test.ts | 4 +- extensions/irc/src/setup-core.ts | 2 +- extensions/irc/src/setup-surface.ts | 14 +++--- extensions/line/src/setup-surface.test.ts | 6 +-- extensions/line/src/setup-surface.ts | 14 +++--- extensions/matrix/src/setup-surface.ts | 6 +-- extensions/msteams/src/setup-surface.ts | 10 ++-- extensions/nextcloud-talk/src/setup-core.ts | 12 ++--- .../nextcloud-talk/src/setup-surface.ts | 14 +++--- extensions/nostr/src/setup-surface.test.ts | 4 +- extensions/nostr/src/setup-surface.ts | 14 +++--- extensions/signal/src/setup-core.ts | 14 +++--- extensions/signal/src/setup-surface.ts | 12 ++--- extensions/slack/src/setup-core.ts | 10 ++-- extensions/slack/src/setup-surface.ts | 14 +++--- extensions/telegram/src/setup-core.ts | 10 ++-- extensions/telegram/src/setup-surface.ts | 14 +++--- extensions/tlon/src/setup-surface.test.ts | 4 +- extensions/twitch/src/setup-surface.ts | 6 +-- extensions/whatsapp/src/onboarding.test.ts | 4 +- extensions/whatsapp/src/setup-surface.ts | 10 ++-- extensions/zalo/src/onboarding.status.test.ts | 4 +- extensions/zalo/src/setup-surface.test.ts | 4 +- extensions/zalo/src/setup-surface.ts | 8 ++-- extensions/zalouser/src/setup-surface.test.ts | 4 +- extensions/zalouser/src/setup-surface.ts | 8 ++-- ...ers.test.ts => setup-flow-helpers.test.ts} | 48 +++++++++---------- .../helpers.ts => setup-flow-helpers.ts} | 42 ++++++++-------- ...nboarding-types.ts => setup-flow-types.ts} | 30 ++++++------ src/channels/plugins/setup-group-access.ts | 4 +- src/channels/plugins/setup-wizard.ts | 44 ++++++++--------- src/commands/channel-setup/types.ts | 1 + src/commands/onboarding/types.ts | 1 - src/plugin-sdk/bluebubbles.ts | 2 +- src/plugin-sdk/feishu.ts | 4 +- src/plugin-sdk/googlechat.ts | 4 +- src/plugin-sdk/index.ts | 2 +- src/plugin-sdk/irc.ts | 2 +- src/plugin-sdk/matrix.ts | 2 +- src/plugin-sdk/mattermost.ts | 2 +- src/plugin-sdk/msteams.ts | 4 +- src/plugin-sdk/nextcloud-talk.ts | 2 +- src/plugin-sdk/zalo.ts | 2 +- src/plugin-sdk/zalouser.ts | 2 +- 56 files changed, 265 insertions(+), 265 deletions(-) rename src/channels/plugins/{onboarding/helpers.test.ts => setup-flow-helpers.test.ts} (96%) rename src/channels/plugins/{onboarding/helpers.ts => setup-flow-helpers.ts} (94%) rename src/channels/plugins/{onboarding-types.ts => setup-flow-types.ts} (75%) create mode 100644 src/commands/channel-setup/types.ts delete mode 100644 src/commands/onboarding/types.ts diff --git a/extensions/bluebubbles/src/setup-core.ts b/extensions/bluebubbles/src/setup-core.ts index 930fa29a64e..bea84e6cd2f 100644 --- a/extensions/bluebubbles/src/setup-core.ts +++ b/extensions/bluebubbles/src/setup-core.ts @@ -1,4 +1,4 @@ -import { setTopLevelChannelDmPolicyWithAllowFrom } from "../../../src/channels/plugins/onboarding/helpers.js"; +import { setTopLevelChannelDmPolicyWithAllowFrom } from "../../../src/channels/plugins/setup-flow-helpers.js"; import { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, diff --git a/extensions/bluebubbles/src/setup-surface.test.ts b/extensions/bluebubbles/src/setup-surface.test.ts index bc9c93735b7..5093c757b06 100644 --- a/extensions/bluebubbles/src/setup-surface.test.ts +++ b/extensions/bluebubbles/src/setup-surface.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { resolveBlueBubblesAccount } from "./accounts.js"; @@ -27,8 +27,8 @@ async function createBlueBubblesConfigureAdapter() { }).config.allowFrom ?? [], }, setup: blueBubblesSetupAdapter, - } as Parameters[0]["plugin"]; - return buildChannelOnboardingAdapterFromSetupWizard({ + } as Parameters[0]["plugin"]; + return buildChannelSetupFlowAdapterFromSetupWizard({ plugin, wizard: blueBubblesSetupWizard, }); diff --git a/extensions/bluebubbles/src/setup-surface.ts b/extensions/bluebubbles/src/setup-surface.ts index f4ee2d98db4..a331aec7d43 100644 --- a/extensions/bluebubbles/src/setup-surface.ts +++ b/extensions/bluebubbles/src/setup-surface.ts @@ -1,8 +1,8 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { mergeAllowFromEntries, - resolveOnboardingAccountId, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + resolveSetupAccountId, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DmPolicy } from "../../../src/config/types.js"; @@ -55,7 +55,7 @@ async function promptBlueBubblesAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = resolveOnboardingAccountId({ + const accountId = resolveSetupAccountId({ accountId: params.accountId, defaultAccountId: resolveDefaultBlueBubblesAccountId(params.cfg), }); @@ -148,7 +148,7 @@ function validateBlueBubblesWebhookPath(value: string): string | undefined { return undefined; } -const dmPolicy: ChannelOnboardingDmPolicy = { +const dmPolicy: ChannelSetupDmPolicy = { label: "BlueBubbles", channel, policyKey: "channels.bluebubbles.dmPolicy", diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index f75a0312416..f130888cc2b 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -1,12 +1,12 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, patchChannelConfigForAccount, setLegacyChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + setSetupChannelEnabled, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, @@ -140,7 +140,7 @@ export const discordSetupAdapter: ChannelSetupAdapter = { export function createDiscordSetupWizardProxy( loadWizard: () => Promise<{ discordSetupWizard: ChannelSetupWizard }>, ) { - const discordDmPolicy: ChannelOnboardingDmPolicy = { + const discordDmPolicy: ChannelSetupDmPolicy = { label: "Discord", channel, policyKey: "channels.discord.dmPolicy", @@ -343,6 +343,6 @@ export function createDiscordSetupWizardProxy( }), }, dmPolicy: discordDmPolicy, - disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index 610b79a5efa..36382eae756 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -1,14 +1,14 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, patchChannelConfigForAccount, promptLegacyChannelAllowFrom, - resolveOnboardingAccountId, + resolveSetupAccountId, setLegacyChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + setSetupChannelEnabled, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; @@ -59,7 +59,7 @@ async function promptDiscordAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = resolveOnboardingAccountId({ + const accountId = resolveSetupAccountId({ accountId: params.accountId, defaultAccountId: resolveDefaultDiscordAccountId(params.cfg), }); @@ -92,7 +92,7 @@ async function promptDiscordAllowFrom(params: { }); } -const discordDmPolicy: ChannelOnboardingDmPolicy = { +const discordDmPolicy: ChannelSetupDmPolicy = { label: "Discord", channel, policyKey: "channels.discord.dmPolicy", @@ -273,5 +273,5 @@ export const discordSetupWizard: ChannelSetupWizard = { }), }, dmPolicy: discordDmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; diff --git a/extensions/feishu/src/onboarding.status.test.ts b/extensions/feishu/src/onboarding.status.test.ts index 4f3b853a1e2..94488a72bfa 100644 --- a/extensions/feishu/src/onboarding.status.test.ts +++ b/extensions/feishu/src/onboarding.status.test.ts @@ -1,9 +1,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/feishu"; import { describe, expect, it } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { feishuPlugin } from "./channel.js"; -const feishuConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const feishuConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: feishuPlugin, wizard: feishuPlugin.setupWizard!, }); diff --git a/extensions/feishu/src/onboarding.test.ts b/extensions/feishu/src/onboarding.test.ts index 2a444964442..f46aef482ba 100644 --- a/extensions/feishu/src/onboarding.test.ts +++ b/extensions/feishu/src/onboarding.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; vi.mock("./probe.js", () => ({ probeFeishu: vi.fn(async () => ({ ok: false, error: "mocked" })), @@ -56,7 +56,7 @@ async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: st }); } -const feishuConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const feishuConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: feishuPlugin, wizard: feishuPlugin.setupWizard!, }); diff --git a/extensions/feishu/src/setup-surface.ts b/extensions/feishu/src/setup-surface.ts index 567ccea1a7e..1c0f966e01e 100644 --- a/extensions/feishu/src/setup-surface.ts +++ b/extensions/feishu/src/setup-surface.ts @@ -1,4 +1,3 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { buildSingleChannelSecretPromptState, mergeAllowFromEntries, @@ -6,8 +5,9 @@ import { setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, - splitOnboardingEntries, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DmPolicy } from "../../../src/config/types.js"; @@ -115,7 +115,7 @@ function isFeishuConfigured(cfg: OpenClawConfig): boolean { async function promptFeishuAllowFrom(params: { cfg: OpenClawConfig; - prompter: Parameters>[0]["prompter"]; + prompter: Parameters>[0]["prompter"]; }): Promise { const existing = params.cfg.channels?.feishu?.allowFrom ?? []; await params.prompter.note( @@ -136,7 +136,7 @@ async function promptFeishuAllowFrom(params: { initialValue: existing[0] ? String(existing[0]) : undefined, validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }); - const parts = splitOnboardingEntries(String(entry)); + const parts = splitSetupEntries(String(entry)); if (parts.length === 0) { await params.prompter.note("Enter at least one user.", "Feishu allowlist"); continue; @@ -177,7 +177,7 @@ async function promptFeishuAppId(params: { ).trim(); } -const feishuDmPolicy: ChannelOnboardingDmPolicy = { +const feishuDmPolicy: ChannelSetupDmPolicy = { label: "Feishu", channel, policyKey: "channels.feishu.dmPolicy", @@ -458,7 +458,7 @@ export const feishuSetupWizard: ChannelSetupWizard = { initialValue: existing.length > 0 ? existing.map(String).join(", ") : undefined, }); if (entry) { - const parts = splitOnboardingEntries(String(entry)); + const parts = splitSetupEntries(String(entry)); if (parts.length > 0) { next = setFeishuGroupAllowFrom(next, parts); } diff --git a/extensions/googlechat/src/setup-surface.test.ts b/extensions/googlechat/src/setup-surface.test.ts index ab09435f67e..4be1a1bbff0 100644 --- a/extensions/googlechat/src/setup-surface.test.ts +++ b/extensions/googlechat/src/setup-surface.test.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk/googlechat"; import { describe, expect, it, vi } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { googlechatPlugin } from "./channel.js"; @@ -26,7 +26,7 @@ function createPrompter(overrides: Partial): WizardPrompter { }; } -const googlechatConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const googlechatConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: googlechatPlugin, wizard: googlechatPlugin.setupWizard!, }); diff --git a/extensions/googlechat/src/setup-surface.ts b/extensions/googlechat/src/setup-surface.ts index 64fe7837fa3..9b18d2fad4f 100644 --- a/extensions/googlechat/src/setup-surface.ts +++ b/extensions/googlechat/src/setup-surface.ts @@ -1,10 +1,10 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { addWildcardAllowFrom, mergeAllowFromEntries, setTopLevelChannelDmPolicyWithAllowFrom, - splitOnboardingEntries, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { applySetupAccountConfigPatch, migrateBaseNameToDefaultAccount, @@ -48,7 +48,7 @@ function setGoogleChatDmPolicy(cfg: OpenClawConfig, policy: DmPolicy) { async function promptAllowFrom(params: { cfg: OpenClawConfig; - prompter: Parameters>[0]["prompter"]; + prompter: Parameters>[0]["prompter"]; }): Promise { const current = params.cfg.channels?.googlechat?.dm?.allowFrom ?? []; const entry = await params.prompter.text({ @@ -57,7 +57,7 @@ async function promptAllowFrom(params: { initialValue: current[0] ? String(current[0]) : undefined, validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }); - const parts = splitOnboardingEntries(String(entry)); + const parts = splitSetupEntries(String(entry)); const unique = mergeAllowFromEntries(undefined, parts); return { ...params.cfg, @@ -76,7 +76,7 @@ async function promptAllowFrom(params: { }; } -const googlechatDmPolicy: ChannelOnboardingDmPolicy = { +const googlechatDmPolicy: ChannelSetupDmPolicy = { label: "Google Chat", channel, policyKey: "channels.googlechat.dm.policy", diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts index 69a8072bd59..0beb217f305 100644 --- a/extensions/imessage/src/setup-core.ts +++ b/extensions/imessage/src/setup-core.ts @@ -1,10 +1,10 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { - parseOnboardingEntriesAllowingWildcard, + parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, setChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + setSetupChannelEnabled, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, @@ -25,7 +25,7 @@ import { normalizeIMessageHandle } from "./targets.js"; const channel = "imessage" as const; export function parseIMessageAllowFromEntries(raw: string): { entries: string[]; error?: string } { - return parseOnboardingEntriesAllowingWildcard(raw, (entry) => { + return parseSetupEntriesAllowingWildcard(raw, (entry) => { const lower = entry.toLowerCase(); if (lower.startsWith("chat_id:")) { const id = entry.slice("chat_id:".length).trim(); @@ -157,7 +157,7 @@ export const imessageSetupAdapter: ChannelSetupAdapter = { export function createIMessageSetupWizardProxy( loadWizard: () => Promise<{ imessageSetupWizard: ChannelSetupWizard }>, ) { - const imessageDmPolicy: ChannelOnboardingDmPolicy = { + const imessageDmPolicy: ChannelSetupDmPolicy = { label: "iMessage", channel, policyKey: "channels.imessage.dmPolicy", @@ -231,6 +231,6 @@ export function createIMessageSetupWizardProxy( ], }, dmPolicy: imessageDmPolicy, - disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } diff --git a/extensions/imessage/src/setup-surface.ts b/extensions/imessage/src/setup-surface.ts index 90fcf648e60..722cdb172c4 100644 --- a/extensions/imessage/src/setup-surface.ts +++ b/extensions/imessage/src/setup-surface.ts @@ -1,10 +1,10 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { - parseOnboardingEntriesAllowingWildcard, + parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, setChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + setSetupChannelEnabled, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { detectBinary } from "../../../src/commands/onboard-helpers.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; @@ -50,7 +50,7 @@ async function promptIMessageAllowFrom(params: { }); } -const imessageDmPolicy: ChannelOnboardingDmPolicy = { +const imessageDmPolicy: ChannelSetupDmPolicy = { label: "iMessage", channel, policyKey: "channels.imessage.dmPolicy", @@ -129,7 +129,7 @@ export const imessageSetupWizard: ChannelSetupWizard = { ], }, dmPolicy: imessageDmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; export { imessageSetupAdapter, parseIMessageAllowFromEntries }; diff --git a/extensions/irc/src/onboarding.test.ts b/extensions/irc/src/onboarding.test.ts index 38738d1e484..883f15fe1b1 100644 --- a/extensions/irc/src/onboarding.test.ts +++ b/extensions/irc/src/onboarding.test.ts @@ -1,6 +1,6 @@ import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/irc"; import { describe, expect, it, vi } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { ircPlugin } from "./channel.js"; import type { CoreConfig } from "./types.js"; @@ -27,7 +27,7 @@ function createPrompter(overrides: Partial): WizardPrompter { }; } -const ircConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const ircConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: ircPlugin, wizard: ircPlugin.setupWizard!, }); diff --git a/extensions/irc/src/setup-core.ts b/extensions/irc/src/setup-core.ts index 45f9041f973..d1603dee476 100644 --- a/extensions/irc/src/setup-core.ts +++ b/extensions/irc/src/setup-core.ts @@ -1,7 +1,7 @@ import { setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../../../src/channels/plugins/onboarding/helpers.js"; +} from "../../../src/channels/plugins/setup-flow-helpers.js"; import { applyAccountNameToChannelSection, patchScopedAccountConfig, diff --git a/extensions/irc/src/setup-surface.ts b/extensions/irc/src/setup-surface.ts index 63a7bec920b..bde9f603593 100644 --- a/extensions/irc/src/setup-surface.ts +++ b/extensions/irc/src/setup-surface.ts @@ -1,8 +1,8 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { - resolveOnboardingAccountId, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + resolveSetupAccountId, + setSetupChannelEnabled, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { DmPolicy } from "../../../src/config/types.js"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; @@ -165,7 +165,7 @@ async function promptIrcNickServConfig(params: { }); } -const ircDmPolicy: ChannelOnboardingDmPolicy = { +const ircDmPolicy: ChannelSetupDmPolicy = { label: "IRC", channel, policyKey: "channels.irc.dmPolicy", @@ -176,7 +176,7 @@ const ircDmPolicy: ChannelOnboardingDmPolicy = { await promptIrcAllowFrom({ cfg: cfg as CoreConfig, prompter, - accountId: resolveOnboardingAccountId({ + accountId: resolveSetupAccountId({ accountId, defaultAccountId: resolveDefaultIrcAccountId(cfg as CoreConfig), }), @@ -458,7 +458,7 @@ export const ircSetupWizard: ChannelSetupWizard = { ], }, dmPolicy: ircDmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; export { ircSetupAdapter }; diff --git a/extensions/line/src/setup-surface.test.ts b/extensions/line/src/setup-surface.test.ts index 9fbddc19675..01a3024fc3a 100644 --- a/extensions/line/src/setup-surface.test.ts +++ b/extensions/line/src/setup-surface.test.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/line"; import { describe, expect, it, vi } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { listLineAccountIds, resolveDefaultLineAccountId, @@ -30,7 +30,7 @@ function createPrompter(overrides: Partial = {}): WizardPrompter }; } -const lineConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const lineConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: { id: "line", meta: { label: "LINE" }, @@ -41,7 +41,7 @@ const lineConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ resolveLineAccount({ cfg, accountId: accountId ?? undefined }).config.allowFrom, }, setup: lineSetupAdapter, - } as Parameters[0]["plugin"], + } as Parameters[0]["plugin"], wizard: lineSetupWizard, }); diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index 37167723cf7..705c89a44f9 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -1,9 +1,9 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { - setOnboardingChannelEnabled, + setSetupChannelEnabled, setTopLevelChannelDmPolicyWithAllowFrom, - splitOnboardingEntries, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { resolveLineAccount } from "../../../src/line/accounts.js"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; @@ -35,7 +35,7 @@ const LINE_ALLOW_FROM_HELP_LINES = [ `Docs: ${formatDocsLink("/channels/line", "channels/line")}`, ]; -const lineDmPolicy: ChannelOnboardingDmPolicy = { +const lineDmPolicy: ChannelSetupDmPolicy = { label: "LINE", channel, policyKey: "channels.line.dmPolicy", @@ -169,7 +169,7 @@ export const lineSetupWizard: ChannelSetupWizard = { placeholder: "U1234567890abcdef1234567890abcdef", invalidWithoutCredentialNote: "LINE allowFrom requires raw user ids like U1234567890abcdef1234567890abcdef.", - parseInputs: splitOnboardingEntries, + parseInputs: splitSetupEntries, parseId: parseLineAllowFromId, resolveEntries: async ({ entries }) => entries.map((entry) => { @@ -198,5 +198,5 @@ export const lineSetupWizard: ChannelSetupWizard = { `Docs: ${formatDocsLink("/channels/line", "channels/line")}`, ], }, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; diff --git a/extensions/matrix/src/setup-surface.ts b/extensions/matrix/src/setup-surface.ts index b475b6bf742..0dcff40fb38 100644 --- a/extensions/matrix/src/setup-surface.ts +++ b/extensions/matrix/src/setup-surface.ts @@ -1,11 +1,11 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { addWildcardAllowFrom, buildSingleChannelSecretPromptState, mergeAllowFromEntries, promptSingleChannelSecretInput, setTopLevelChannelGroupPolicy, -} from "../../../src/channels/plugins/onboarding/helpers.js"; +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DmPolicy } from "../../../src/config/types.js"; @@ -242,7 +242,7 @@ const matrixGroupAccess: NonNullable = { setMatrixGroupRooms(cfg as CoreConfig, resolved as string[]), }; -const matrixDmPolicy: ChannelOnboardingDmPolicy = { +const matrixDmPolicy: ChannelSetupDmPolicy = { label: "Matrix", channel, policyKey: "channels.matrix.dm.policy", diff --git a/extensions/msteams/src/setup-surface.ts b/extensions/msteams/src/setup-surface.ts index 9e39a24563e..8336e0ae976 100644 --- a/extensions/msteams/src/setup-surface.ts +++ b/extensions/msteams/src/setup-surface.ts @@ -1,11 +1,11 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { mergeAllowFromEntries, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, - splitOnboardingEntries, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DmPolicy, MSTeamsTeamConfig } from "../../../src/config/types.js"; @@ -93,7 +93,7 @@ async function promptMSTeamsAllowFrom(params: { initialValue: existing[0] ? String(existing[0]) : undefined, validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }); - const parts = splitOnboardingEntries(String(entry)); + const parts = splitSetupEntries(String(entry)); if (parts.length === 0) { await params.prompter.note("Enter at least one user.", "MS Teams allowlist"); continue; @@ -280,7 +280,7 @@ const msteamsGroupAccess: NonNullable = { setMSTeamsTeamsAllowlist(cfg, resolved as Array<{ teamKey: string; channelKey?: string }>), }; -const msteamsDmPolicy: ChannelOnboardingDmPolicy = { +const msteamsDmPolicy: ChannelSetupDmPolicy = { label: "MS Teams", channel, policyKey: "channels.msteams.dmPolicy", diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index 9deafc5f71a..61ef7e47a85 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -1,10 +1,10 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { mergeAllowFromEntries, - resolveOnboardingAccountId, - setOnboardingChannelEnabled, + resolveSetupAccountId, + setSetupChannelEnabled, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../../../src/channels/plugins/onboarding/helpers.js"; +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { applyAccountNameToChannelSection, patchScopedAccountConfig, @@ -163,7 +163,7 @@ async function promptNextcloudTalkAllowFromForAccount(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = resolveOnboardingAccountId({ + const accountId = resolveSetupAccountId({ accountId: params.accountId, defaultAccountId: resolveDefaultNextcloudTalkAccountId(params.cfg as CoreConfig), }); @@ -174,7 +174,7 @@ async function promptNextcloudTalkAllowFromForAccount(params: { }); } -const nextcloudTalkDmPolicy: ChannelOnboardingDmPolicy = { +const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { label: "Nextcloud Talk", channel, policyKey: "channels.nextcloud-talk.dmPolicy", diff --git a/extensions/nextcloud-talk/src/setup-surface.ts b/extensions/nextcloud-talk/src/setup-surface.ts index 4fcb874b5d3..64c0fc5a7a1 100644 --- a/extensions/nextcloud-talk/src/setup-surface.ts +++ b/extensions/nextcloud-talk/src/setup-surface.ts @@ -1,10 +1,10 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { mergeAllowFromEntries, - resolveOnboardingAccountId, - setOnboardingChannelEnabled, + resolveSetupAccountId, + setSetupChannelEnabled, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../../../src/channels/plugins/onboarding/helpers.js"; +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; @@ -85,7 +85,7 @@ async function promptNextcloudTalkAllowFromForAccount(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = resolveOnboardingAccountId({ + const accountId = resolveSetupAccountId({ accountId: params.accountId, defaultAccountId: resolveDefaultNextcloudTalkAccountId(params.cfg as CoreConfig), }); @@ -96,7 +96,7 @@ async function promptNextcloudTalkAllowFromForAccount(params: { }); } -const nextcloudTalkDmPolicy: ChannelOnboardingDmPolicy = { +const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { label: "Nextcloud Talk", channel, policyKey: "channels.nextcloud-talk.dmPolicy", @@ -272,7 +272,7 @@ export const nextcloudTalkSetupWizard: ChannelSetupWizard = { }, ], dmPolicy: nextcloudTalkDmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; export { nextcloudTalkSetupAdapter }; diff --git a/extensions/nostr/src/setup-surface.test.ts b/extensions/nostr/src/setup-surface.test.ts index c9c62e14c9a..0bd1b3f29a3 100644 --- a/extensions/nostr/src/setup-surface.test.ts +++ b/extensions/nostr/src/setup-surface.test.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/nostr"; import { describe, expect, it, vi } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { nostrPlugin } from "./channel.js"; @@ -25,7 +25,7 @@ function createPrompter(overrides: Partial): WizardPrompter { }; } -const nostrConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const nostrConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: nostrPlugin, wizard: nostrPlugin.setupWizard!, }); diff --git a/extensions/nostr/src/setup-surface.ts b/extensions/nostr/src/setup-surface.ts index d58a4c4fbdc..800b2705258 100644 --- a/extensions/nostr/src/setup-surface.ts +++ b/extensions/nostr/src/setup-surface.ts @@ -1,11 +1,11 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { mergeAllowFromEntries, - parseOnboardingEntriesWithParser, + parseSetupEntriesWithParser, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, - splitOnboardingEntries, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; @@ -76,7 +76,7 @@ function setNostrAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawCo } function parseRelayUrls(raw: string): { relays: string[]; error?: string } { - const entries = splitOnboardingEntries(raw); + const entries = splitSetupEntries(raw); const relays: string[] = []; for (const entry of entries) { try { @@ -93,7 +93,7 @@ function parseRelayUrls(raw: string): { relays: string[]; error?: string } { } function parseNostrAllowFrom(raw: string): { entries: string[]; error?: string } { - return parseOnboardingEntriesWithParser(raw, (entry) => { + return parseSetupEntriesWithParser(raw, (entry) => { const cleaned = entry.replace(/^nostr:/i, "").trim(); try { return { value: normalizePubkey(cleaned) }; @@ -125,7 +125,7 @@ async function promptNostrAllowFrom(params: { return setNostrAllowFrom(params.cfg, mergeAllowFromEntries(existing, parsed.entries)); } -const nostrDmPolicy: ChannelOnboardingDmPolicy = { +const nostrDmPolicy: ChannelSetupDmPolicy = { label: "Nostr", channel, policyKey: "channels.nostr.dmPolicy", diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index 2f46c4d4c4c..1b5b00d8264 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -1,10 +1,10 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { - parseOnboardingEntriesAllowingWildcard, + parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, setChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + setSetupChannelEnabled, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, @@ -51,7 +51,7 @@ function isUuidLike(value: string): boolean { } export function parseSignalAllowFromEntries(raw: string): { entries: string[]; error?: string } { - return parseOnboardingEntriesAllowingWildcard(raw, (entry) => { + return parseSetupEntriesAllowingWildcard(raw, (entry) => { if (entry.toLowerCase().startsWith("uuid:")) { const id = entry.slice("uuid:".length).trim(); if (!id) { @@ -186,7 +186,7 @@ export const signalSetupAdapter: ChannelSetupAdapter = { export function createSignalSetupWizardProxy( loadWizard: () => Promise<{ signalSetupWizard: ChannelSetupWizard }>, ) { - const signalDmPolicy: ChannelOnboardingDmPolicy = { + const signalDmPolicy: ChannelSetupDmPolicy = { label: "Signal", channel, policyKey: "channels.signal.dmPolicy", @@ -270,6 +270,6 @@ export function createSignalSetupWizardProxy( ], }, dmPolicy: signalDmPolicy, - disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index 822df4caf10..62cb02b78ab 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -1,10 +1,10 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { - parseOnboardingEntriesAllowingWildcard, + parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, setChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + setSetupChannelEnabled, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { formatCliCommand } from "../../../src/cli/command-format.js"; import { detectBinary } from "../../../src/commands/onboard-helpers.js"; @@ -56,7 +56,7 @@ async function promptSignalAllowFrom(params: { }); } -const signalDmPolicy: ChannelOnboardingDmPolicy = { +const signalDmPolicy: ChannelSetupDmPolicy = { label: "Signal", channel, policyKey: "channels.signal.dmPolicy", @@ -179,7 +179,7 @@ export const signalSetupWizard: ChannelSetupWizard = { ], }, dmPolicy: signalDmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; export { normalizeSignalAccountInput, parseSignalAllowFromEntries, signalSetupAdapter }; diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index c30f0134009..0aff9fc50a8 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -1,4 +1,3 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { noteChannelLookupFailure, noteChannelLookupSummary, @@ -6,8 +5,9 @@ import { patchChannelConfigForAccount, setAccountGroupPolicyForChannel, setLegacyChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + setSetupChannelEnabled, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, @@ -216,7 +216,7 @@ export const slackSetupAdapter: ChannelSetupAdapter = { export function createSlackSetupWizardProxy( loadWizard: () => Promise<{ slackSetupWizard: ChannelSetupWizard }>, ) { - const slackDmPolicy: ChannelOnboardingDmPolicy = { + const slackDmPolicy: ChannelSetupDmPolicy = { label: "Slack", channel, policyKey: "channels.slack.dmPolicy", @@ -490,6 +490,6 @@ export function createSlackSetupWizardProxy( resolved: unknown; }) => setSlackChannelAllowlist(cfg, accountId, resolved as string[]), }, - disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index dafcad32f74..4088e0d0ceb 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -1,15 +1,15 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, patchChannelConfigForAccount, promptLegacyChannelAllowFrom, - resolveOnboardingAccountId, + resolveSetupAccountId, setAccountGroupPolicyForChannel, setLegacyChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + setSetupChannelEnabled, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard, ChannelSetupWizardAllowFromEntry, @@ -166,7 +166,7 @@ async function promptSlackAllowFrom(params: { prompter: WizardPrompter; accountId?: string; }): Promise { - const accountId = resolveOnboardingAccountId({ + const accountId = resolveSetupAccountId({ accountId: params.accountId, defaultAccountId: resolveDefaultSlackAccountId(params.cfg), }); @@ -210,7 +210,7 @@ async function promptSlackAllowFrom(params: { }); } -const slackDmPolicy: ChannelOnboardingDmPolicy = { +const slackDmPolicy: ChannelSetupDmPolicy = { label: "Slack", channel, policyKey: "channels.slack.dmPolicy", @@ -424,5 +424,5 @@ export const slackSetupWizard: ChannelSetupWizard = { applyAllowlist: ({ cfg, accountId, resolved }) => setSlackChannelAllowlist(cfg, accountId, resolved as string[]), }, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts index fe9c9993035..1a3d17e68fd 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -1,7 +1,7 @@ import { patchChannelConfigForAccount, - splitOnboardingEntries, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; import { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, @@ -73,7 +73,7 @@ export async function promptTelegramAllowFromForAccount(params: { cfg: OpenClawConfig; prompter: Parameters< NonNullable< - import("../../../src/channels/plugins/onboarding-types.js").ChannelOnboardingDmPolicy["promptAllowFrom"] + import("../../../src/channels/plugins/setup-flow-types.js").ChannelSetupDmPolicy["promptAllowFrom"] > >[0]["prompter"]; accountId?: string; @@ -88,7 +88,7 @@ export async function promptTelegramAllowFromForAccount(params: { ); } const { promptResolvedAllowFrom } = - await import("../../../src/channels/plugins/onboarding/helpers.js"); + await import("../../../src/channels/plugins/setup-flow-helpers.js"); const unique = await promptResolvedAllowFrom({ prompter: params.prompter, existing: resolved.config.allowFrom ?? [], @@ -96,7 +96,7 @@ export async function promptTelegramAllowFromForAccount(params: { message: "Telegram allowFrom (numeric sender id; @username resolves to id)", placeholder: "@username", label: "Telegram allowlist", - parseInputs: splitOnboardingEntries, + parseInputs: splitSetupEntries, parseId: parseTelegramAllowFromId, invalidWithoutTokenNote: "Telegram token missing; use numeric sender ids (usernames require a bot token).", diff --git a/extensions/telegram/src/setup-surface.ts b/extensions/telegram/src/setup-surface.ts index 3fcf09ed7db..ba03f2bb251 100644 --- a/extensions/telegram/src/setup-surface.ts +++ b/extensions/telegram/src/setup-surface.ts @@ -1,10 +1,10 @@ -import { type ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { patchChannelConfigForAccount, setChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, - splitOnboardingEntries, -} from "../../../src/channels/plugins/onboarding/helpers.js"; + setSetupChannelEnabled, + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import { type ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; @@ -22,7 +22,7 @@ import { const channel = "telegram" as const; -const dmPolicy: ChannelOnboardingDmPolicy = { +const dmPolicy: ChannelSetupDmPolicy = { label: "Telegram", channel, policyKey: "channels.telegram.dmPolicy", @@ -89,7 +89,7 @@ export const telegramSetupWizard: ChannelSetupWizard = { placeholder: "@username", invalidWithoutCredentialNote: "Telegram token missing; use numeric sender ids (usernames require a bot token).", - parseInputs: splitOnboardingEntries, + parseInputs: splitSetupEntries, parseId: parseTelegramAllowFromId, resolveEntries: async ({ credentialValues, entries }) => resolveTelegramAllowFromEntries({ @@ -105,7 +105,7 @@ export const telegramSetupWizard: ChannelSetupWizard = { }), }, dmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }; export { parseTelegramAllowFromId, telegramSetupAdapter }; diff --git a/extensions/tlon/src/setup-surface.test.ts b/extensions/tlon/src/setup-surface.test.ts index bb638fc3018..9d3f432b46c 100644 --- a/extensions/tlon/src/setup-surface.test.ts +++ b/extensions/tlon/src/setup-surface.test.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig, RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/tlon"; import { describe, expect, it, vi } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { tlonPlugin } from "./channel.js"; @@ -26,7 +26,7 @@ function createPrompter(overrides: Partial): WizardPrompter { }; } -const tlonConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const tlonConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: tlonPlugin, wizard: tlonPlugin.setupWizard!, }); diff --git a/extensions/twitch/src/setup-surface.ts b/extensions/twitch/src/setup-surface.ts index bff81f47fff..7d4129d2ebd 100644 --- a/extensions/twitch/src/setup-surface.ts +++ b/extensions/twitch/src/setup-surface.ts @@ -2,7 +2,7 @@ * Twitch setup wizard surface for CLI setup. */ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; @@ -183,7 +183,7 @@ export async function configureWithEnvToken( account: TwitchAccountConfig | null, envToken: string, forceAllowFrom: boolean, - dmPolicy: ChannelOnboardingDmPolicy, + dmPolicy: ChannelSetupDmPolicy, ): Promise<{ cfg: OpenClawConfig } | null> { const useEnv = await prompter.confirm({ message: "Twitch env var OPENCLAW_TWITCH_ACCESS_TOKEN detected. Use env token?", @@ -247,7 +247,7 @@ function setTwitchGroupPolicy( return setTwitchAccessControl(cfg, allowedRoles, true); } -const twitchDmPolicy: ChannelOnboardingDmPolicy = { +const twitchDmPolicy: ChannelSetupDmPolicy = { label: "Twitch", channel, policyKey: "channels.twitch.allowedRoles", diff --git a/extensions/whatsapp/src/onboarding.test.ts b/extensions/whatsapp/src/onboarding.test.ts index bf816e3f03d..e28766058af 100644 --- a/extensions/whatsapp/src/onboarding.test.ts +++ b/extensions/whatsapp/src/onboarding.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import type { RuntimeEnv } from "../../../src/runtime.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; @@ -83,7 +83,7 @@ function createRuntime(): RuntimeEnv { } as unknown as RuntimeEnv; } -const whatsappConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const whatsappConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: whatsappPlugin, wizard: whatsappPlugin.setupWizard!, }); diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index e0e9fa3191b..e9b5b8aeb0b 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -2,9 +2,9 @@ import path from "node:path"; import { loginWeb } from "../../../src/channel-web.js"; import { normalizeAllowFromEntries, - splitOnboardingEntries, -} from "../../../src/channels/plugins/onboarding/helpers.js"; -import { setOnboardingChannelEnabled } from "../../../src/channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import { setSetupChannelEnabled } from "../../../src/channels/plugins/setup-flow-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { formatCliCommand } from "../../../src/cli/command-format.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; @@ -96,7 +96,7 @@ async function applyWhatsAppOwnerAllowlist(params: { } function parseWhatsAppAllowFromEntries(raw: string): { entries: string[]; invalidEntry?: string } { - const parts = splitOnboardingEntries(raw); + const parts = splitSetupEntries(raw); if (parts.length === 0) { return { entries: [] }; } @@ -330,7 +330,7 @@ export const whatsappSetupWizard: ChannelSetupWizard = { }); return { cfg: next }; }, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), onAccountRecorded: (accountId, options) => { options?.onWhatsAppAccountId?.(accountId); }, diff --git a/extensions/zalo/src/onboarding.status.test.ts b/extensions/zalo/src/onboarding.status.test.ts index 4db31735c94..65e5591cbae 100644 --- a/extensions/zalo/src/onboarding.status.test.ts +++ b/extensions/zalo/src/onboarding.status.test.ts @@ -1,9 +1,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo"; import { describe, expect, it } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { zaloPlugin } from "./channel.js"; -const zaloConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const zaloConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: zaloPlugin, wizard: zaloPlugin.setupWizard!, }); diff --git a/extensions/zalo/src/setup-surface.test.ts b/extensions/zalo/src/setup-surface.test.ts index 2353a66e453..b5db1019c38 100644 --- a/extensions/zalo/src/setup-surface.test.ts +++ b/extensions/zalo/src/setup-surface.test.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig, RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/zalo"; import { describe, expect, it, vi } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { zaloPlugin } from "./channel.js"; @@ -18,7 +18,7 @@ function createPrompter(overrides: Partial): WizardPrompter { }; } -const zaloConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const zaloConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: zaloPlugin, wizard: zaloPlugin.setupWizard!, }); diff --git a/extensions/zalo/src/setup-surface.ts b/extensions/zalo/src/setup-surface.ts index 125bc322998..b3ad6549c13 100644 --- a/extensions/zalo/src/setup-surface.ts +++ b/extensions/zalo/src/setup-surface.ts @@ -1,11 +1,11 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { buildSingleChannelSecretPromptState, mergeAllowFromEntries, promptSingleChannelSecretInput, runSingleChannelSecretStep, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../../../src/channels/plugins/onboarding/helpers.js"; +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { SecretInput } from "../../../src/config/types.secrets.js"; @@ -122,7 +122,7 @@ async function noteZaloTokenHelp( async function promptZaloAllowFrom(params: { cfg: OpenClawConfig; - prompter: Parameters>[0]["prompter"]; + prompter: Parameters>[0]["prompter"]; accountId: string; }): Promise { const { cfg, prompter, accountId } = params; @@ -182,7 +182,7 @@ async function promptZaloAllowFrom(params: { } as OpenClawConfig; } -const zaloDmPolicy: ChannelOnboardingDmPolicy = { +const zaloDmPolicy: ChannelSetupDmPolicy = { label: "Zalo", channel, policyKey: "channels.zalo.dmPolicy", diff --git a/extensions/zalouser/src/setup-surface.test.ts b/extensions/zalouser/src/setup-surface.test.ts index d28fd8f0ccc..bd96ff2efe0 100644 --- a/extensions/zalouser/src/setup-surface.test.ts +++ b/extensions/zalouser/src/setup-surface.test.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk/zalouser"; import { describe, expect, it, vi } from "vitest"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; vi.mock("./zalo-js.js", async (importOriginal) => { @@ -50,7 +50,7 @@ function createPrompter(overrides: Partial): WizardPrompter { }; } -const zalouserConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +const zalouserConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: zalouserPlugin, wizard: zalouserPlugin.setupWizard!, }); diff --git a/extensions/zalouser/src/setup-surface.ts b/extensions/zalouser/src/setup-surface.ts index 3ce0bd9d066..c7406f50edd 100644 --- a/extensions/zalouser/src/setup-surface.ts +++ b/extensions/zalouser/src/setup-surface.ts @@ -1,8 +1,8 @@ -import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { mergeAllowFromEntries, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../../../src/channels/plugins/onboarding/helpers.js"; +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-flow-types.js"; import { patchScopedAccountConfig } from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; @@ -91,7 +91,7 @@ async function noteZalouserHelp( async function promptZalouserAllowFrom(params: { cfg: OpenClawConfig; - prompter: Parameters>[0]["prompter"]; + prompter: Parameters>[0]["prompter"]; accountId: string; }): Promise { const { cfg, prompter, accountId } = params; @@ -144,7 +144,7 @@ async function promptZalouserAllowFrom(params: { } } -const zalouserDmPolicy: ChannelOnboardingDmPolicy = { +const zalouserDmPolicy: ChannelSetupDmPolicy = { label: "Zalo Personal", channel, policyKey: "channels.zalouser.dmPolicy", diff --git a/src/channels/plugins/onboarding/helpers.test.ts b/src/channels/plugins/setup-flow-helpers.test.ts similarity index 96% rename from src/channels/plugins/onboarding/helpers.test.ts rename to src/channels/plugins/setup-flow-helpers.test.ts index f4d4c0c2f5a..3b24600372c 100644 --- a/src/channels/plugins/onboarding/helpers.test.ts +++ b/src/channels/plugins/setup-flow-helpers.test.ts @@ -1,9 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; const promptAccountIdSdkMock = vi.hoisted(() => vi.fn(async () => "default")); -vi.mock("../../../plugin-sdk/onboarding.js", () => ({ +vi.mock("../../plugin-sdk/onboarding.js", () => ({ promptAccountId: promptAccountIdSdkMock, })); @@ -14,17 +14,17 @@ import { noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, - parseOnboardingEntriesAllowingWildcard, + parseSetupEntriesAllowingWildcard, patchChannelConfigForAccount, patchLegacyDmChannelConfig, promptLegacyChannelAllowFrom, - parseOnboardingEntriesWithParser, + parseSetupEntriesWithParser, promptParsedAllowFromForScopedChannel, promptSingleChannelSecretInput, promptSingleChannelToken, promptResolvedAllowFrom, resolveAccountIdForConfigure, - resolveOnboardingAccountId, + resolveSetupAccountId, setAccountAllowFromForChannel, setAccountGroupPolicyForChannel, setChannelDmPolicyWithAllowFrom, @@ -33,9 +33,9 @@ import { setTopLevelChannelGroupPolicy, setLegacyChannelAllowFrom, setLegacyChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, - splitOnboardingEntries, -} from "./helpers.js"; + setSetupChannelEnabled, + splitSetupEntries, +} from "./setup-flow-helpers.js"; function createPrompter(inputs: string[]) { return { @@ -464,7 +464,7 @@ describe("promptParsedAllowFromForScopedChannel", () => { message: "msg", placeholder: "placeholder", parseEntries: (raw) => - parseOnboardingEntriesWithParser(raw, (entry) => ({ value: entry.toLowerCase() })), + parseSetupEntriesWithParser(raw, (entry) => ({ value: entry.toLowerCase() })), getExistingAllowFrom: ({ cfg }) => cfg.channels?.imessage?.allowFrom ?? [], }); @@ -748,7 +748,7 @@ describe("patchChannelConfigForAccount", () => { }); }); -describe("setOnboardingChannelEnabled", () => { +describe("setSetupChannelEnabled", () => { it("updates enabled and keeps existing channel fields", () => { const cfg: OpenClawConfig = { channels: { @@ -759,13 +759,13 @@ describe("setOnboardingChannelEnabled", () => { }, }; - const next = setOnboardingChannelEnabled(cfg, "discord", false); + const next = setSetupChannelEnabled(cfg, "discord", false); expect(next.channels?.discord?.enabled).toBe(false); expect(next.channels?.discord?.token).toBe("abc"); }); it("creates missing channel config with enabled state", () => { - const next = setOnboardingChannelEnabled({}, "signal", true); + const next = setSetupChannelEnabled({}, "signal", true); expect(next.channels?.signal?.enabled).toBe(true); }); }); @@ -1016,16 +1016,16 @@ describe("setTopLevelChannelGroupPolicy", () => { }); }); -describe("splitOnboardingEntries", () => { +describe("splitSetupEntries", () => { it("splits comma/newline/semicolon input and trims blanks", () => { - expect(splitOnboardingEntries(" alice, bob \ncarol; ;\n")).toEqual(["alice", "bob", "carol"]); + expect(splitSetupEntries(" alice, bob \ncarol; ;\n")).toEqual(["alice", "bob", "carol"]); }); }); -describe("parseOnboardingEntriesWithParser", () => { +describe("parseSetupEntriesWithParser", () => { it("maps entries and de-duplicates parsed values", () => { expect( - parseOnboardingEntriesWithParser(" alice, ALICE ; * ", (entry) => { + parseSetupEntriesWithParser(" alice, ALICE ; * ", (entry) => { if (entry === "*") { return { value: "*" }; } @@ -1038,7 +1038,7 @@ describe("parseOnboardingEntriesWithParser", () => { it("returns parser errors and clears parsed entries", () => { expect( - parseOnboardingEntriesWithParser("ok, bad", (entry) => + parseSetupEntriesWithParser("ok, bad", (entry) => entry === "bad" ? { error: "invalid entry: bad" } : { value: entry }, ), ).toEqual({ @@ -1048,10 +1048,10 @@ describe("parseOnboardingEntriesWithParser", () => { }); }); -describe("parseOnboardingEntriesAllowingWildcard", () => { +describe("parseSetupEntriesAllowingWildcard", () => { it("preserves wildcard and delegates non-wildcard entries", () => { expect( - parseOnboardingEntriesAllowingWildcard(" *, Foo ", (entry) => ({ + parseSetupEntriesAllowingWildcard(" *, Foo ", (entry) => ({ value: entry.toLowerCase(), })), ).toEqual({ @@ -1061,7 +1061,7 @@ describe("parseOnboardingEntriesAllowingWildcard", () => { it("returns parser errors for non-wildcard entries", () => { expect( - parseOnboardingEntriesAllowingWildcard("ok,bad", (entry) => + parseSetupEntriesAllowingWildcard("ok,bad", (entry) => entry === "bad" ? { error: "bad entry" } : { value: entry }, ), ).toEqual({ @@ -1129,10 +1129,10 @@ describe("normalizeAllowFromEntries", () => { }); }); -describe("resolveOnboardingAccountId", () => { +describe("resolveSetupAccountId", () => { it("normalizes provided account ids", () => { expect( - resolveOnboardingAccountId({ + resolveSetupAccountId({ accountId: " Work Account ", defaultAccountId: DEFAULT_ACCOUNT_ID, }), @@ -1141,7 +1141,7 @@ describe("resolveOnboardingAccountId", () => { it("falls back to default account id when input is blank", () => { expect( - resolveOnboardingAccountId({ + resolveSetupAccountId({ accountId: " ", defaultAccountId: "custom-default", }), diff --git a/src/channels/plugins/onboarding/helpers.ts b/src/channels/plugins/setup-flow-helpers.ts similarity index 94% rename from src/channels/plugins/onboarding/helpers.ts rename to src/channels/plugins/setup-flow-helpers.ts index d26999bd3ff..87a208a9a21 100644 --- a/src/channels/plugins/onboarding/helpers.ts +++ b/src/channels/plugins/setup-flow-helpers.ts @@ -1,18 +1,18 @@ import { promptSecretRefForOnboarding, resolveSecretInputModeForEnvSelection, -} from "../../../commands/auth-choice.apply-helpers.js"; -import type { OpenClawConfig } from "../../../config/config.js"; -import type { DmPolicy, GroupPolicy } from "../../../config/types.js"; -import type { SecretInput } from "../../../config/types.secrets.js"; -import { promptAccountId as promptAccountIdSdk } from "../../../plugin-sdk/onboarding.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; -import type { WizardPrompter } from "../../../wizard/prompts.js"; -import type { PromptAccountId, PromptAccountIdParams } from "../onboarding-types.js"; +} from "../../commands/auth-choice.apply-helpers.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { DmPolicy, GroupPolicy } from "../../config/types.js"; +import type { SecretInput } from "../../config/types.secrets.js"; +import { promptAccountId as promptAccountIdSdk } from "../../plugin-sdk/onboarding.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; +import type { WizardPrompter } from "../../wizard/prompts.js"; +import type { PromptAccountId, PromptAccountIdParams } from "./setup-flow-types.js"; import { moveSingleAccountChannelSectionToDefaultAccount, patchScopedAccountConfig, -} from "../setup-helpers.js"; +} from "./setup-helpers.js"; export const promptAccountId: PromptAccountId = async (params: PromptAccountIdParams) => { return await promptAccountIdSdk(params); @@ -34,20 +34,20 @@ export function mergeAllowFromEntries( return [...new Set(merged)]; } -export function splitOnboardingEntries(raw: string): string[] { +export function splitSetupEntries(raw: string): string[] { return raw .split(/[\n,;]+/g) .map((entry) => entry.trim()) .filter(Boolean); } -type ParsedOnboardingEntry = { value: string } | { error: string }; +type ParsedSetupEntry = { value: string } | { error: string }; -export function parseOnboardingEntriesWithParser( +export function parseSetupEntriesWithParser( raw: string, - parseEntry: (entry: string) => ParsedOnboardingEntry, + parseEntry: (entry: string) => ParsedSetupEntry, ): { entries: string[]; error?: string } { - const parts = splitOnboardingEntries(String(raw ?? "")); + const parts = splitSetupEntries(String(raw ?? "")); const entries: string[] = []; for (const part of parts) { const parsed = parseEntry(part); @@ -59,11 +59,11 @@ export function parseOnboardingEntriesWithParser( return { entries: normalizeAllowFromEntries(entries) }; } -export function parseOnboardingEntriesAllowingWildcard( +export function parseSetupEntriesAllowingWildcard( raw: string, - parseEntry: (entry: string) => ParsedOnboardingEntry, + parseEntry: (entry: string) => ParsedSetupEntry, ): { entries: string[]; error?: string } { - return parseOnboardingEntriesWithParser(raw, (entry) => { + return parseSetupEntriesWithParser(raw, (entry) => { if (entry === "*") { return { value: "*" }; } @@ -117,7 +117,7 @@ export function normalizeAllowFromEntries( return [...new Set(normalized)]; } -export function resolveOnboardingAccountId(params: { +export function resolveSetupAccountId(params: { accountId?: string; defaultAccountId: string; }): string { @@ -338,7 +338,7 @@ export function patchLegacyDmChannelConfig(params: { }; } -export function setOnboardingChannelEnabled( +export function setSetupChannelEnabled( cfg: OpenClawConfig, channel: string, enabled: boolean, @@ -656,7 +656,7 @@ export async function promptParsedAllowFromForScopedChannel(params: { accountId: string; }) => Array; }): Promise { - const accountId = resolveOnboardingAccountId({ + const accountId = resolveSetupAccountId({ accountId: params.accountId, defaultAccountId: params.defaultAccountId, }); @@ -799,7 +799,7 @@ export async function promptLegacyChannelAllowFrom(params: { message: params.message, placeholder: params.placeholder, label: params.noteTitle, - parseInputs: splitOnboardingEntries, + parseInputs: splitSetupEntries, parseId: params.parseId, invalidWithoutTokenNote: params.invalidWithoutTokenNote, resolveEntries: params.resolveEntries, diff --git a/src/channels/plugins/onboarding-types.ts b/src/channels/plugins/setup-flow-types.ts similarity index 75% rename from src/channels/plugins/onboarding-types.ts rename to src/channels/plugins/setup-flow-types.ts index 8562e6b06a6..a3887cc7ef2 100644 --- a/src/channels/plugins/onboarding-types.ts +++ b/src/channels/plugins/setup-flow-types.ts @@ -40,7 +40,7 @@ export type PromptAccountIdParams = { export type PromptAccountId = (params: PromptAccountIdParams) => Promise; -export type ChannelOnboardingStatus = { +export type ChannelSetupStatus = { channel: ChannelId; configured: boolean; statusLines: string[]; @@ -48,13 +48,13 @@ export type ChannelOnboardingStatus = { quickstartScore?: number; }; -export type ChannelOnboardingStatusContext = { +export type ChannelSetupStatusContext = { cfg: OpenClawConfig; options?: SetupChannelsOptions; accountOverrides: Partial>; }; -export type ChannelOnboardingConfigureContext = { +export type ChannelSetupConfigureContext = { cfg: OpenClawConfig; runtime: RuntimeEnv; prompter: WizardPrompter; @@ -64,19 +64,19 @@ export type ChannelOnboardingConfigureContext = { forceAllowFrom: boolean; }; -export type ChannelOnboardingResult = { +export type ChannelSetupResult = { cfg: OpenClawConfig; accountId?: string; }; -export type ChannelOnboardingConfiguredResult = ChannelOnboardingResult | "skip"; +export type ChannelSetupConfiguredResult = ChannelSetupResult | "skip"; -export type ChannelOnboardingInteractiveContext = ChannelOnboardingConfigureContext & { +export type ChannelSetupInteractiveContext = ChannelSetupConfigureContext & { configured: boolean; label: string; }; -export type ChannelOnboardingDmPolicy = { +export type ChannelSetupDmPolicy = { label: string; channel: ChannelId; policyKey: string; @@ -90,17 +90,17 @@ export type ChannelOnboardingDmPolicy = { }) => Promise; }; -export type ChannelOnboardingAdapter = { +export type ChannelSetupFlowAdapter = { channel: ChannelId; - getStatus: (ctx: ChannelOnboardingStatusContext) => Promise; - configure: (ctx: ChannelOnboardingConfigureContext) => Promise; + getStatus: (ctx: ChannelSetupStatusContext) => Promise; + configure: (ctx: ChannelSetupConfigureContext) => Promise; configureInteractive?: ( - ctx: ChannelOnboardingInteractiveContext, - ) => Promise; + ctx: ChannelSetupInteractiveContext, + ) => Promise; configureWhenConfigured?: ( - ctx: ChannelOnboardingInteractiveContext, - ) => Promise; - dmPolicy?: ChannelOnboardingDmPolicy; + ctx: ChannelSetupInteractiveContext, + ) => Promise; + dmPolicy?: ChannelSetupDmPolicy; onAccountRecorded?: (accountId: string, options?: SetupChannelsOptions) => void; disable?: (cfg: OpenClawConfig) => OpenClawConfig; }; diff --git a/src/channels/plugins/setup-group-access.ts b/src/channels/plugins/setup-group-access.ts index a757816e9ec..b9130f7de51 100644 --- a/src/channels/plugins/setup-group-access.ts +++ b/src/channels/plugins/setup-group-access.ts @@ -1,10 +1,10 @@ import type { WizardPrompter } from "../../wizard/prompts.js"; -import { splitOnboardingEntries } from "./onboarding/helpers.js"; +import { splitSetupEntries } from "./setup-flow-helpers.js"; export type ChannelAccessPolicy = "allowlist" | "open" | "disabled"; export function parseAllowlistEntries(raw: string): string[] { - return splitOnboardingEntries(String(raw ?? "")); + return splitSetupEntries(String(raw ?? "")); } export function formatAllowlistEntries(entries: string[]): string { diff --git a/src/channels/plugins/setup-wizard.ts b/src/channels/plugins/setup-wizard.ts index 2d4896dd733..66e7765ffe4 100644 --- a/src/channels/plugins/setup-wizard.ts +++ b/src/channels/plugins/setup-wizard.ts @@ -1,19 +1,19 @@ import type { OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; -import type { - ChannelOnboardingAdapter, - ChannelOnboardingConfigureContext, - ChannelOnboardingDmPolicy, - ChannelOnboardingStatus, - ChannelOnboardingStatusContext, -} from "./onboarding-types.js"; import { promptResolvedAllowFrom, resolveAccountIdForConfigure, runSingleChannelSecretStep, - splitOnboardingEntries, -} from "./onboarding/helpers.js"; + splitSetupEntries, +} from "./setup-flow-helpers.js"; +import type { + ChannelSetupFlowAdapter, + ChannelSetupConfigureContext, + ChannelSetupDmPolicy, + ChannelSetupStatus, + ChannelSetupStatusContext, +} from "./setup-flow-types.js"; import { configureChannelAccessWithAllowlist } from "./setup-group-access-configure.js"; import type { ChannelAccessPolicy } from "./setup-group-access.js"; import type { ChannelSetupInput } from "./types.core.js"; @@ -211,9 +211,9 @@ export type ChannelSetupWizardPrepare = (params: { cfg: OpenClawConfig; accountId: string; credentialValues: ChannelSetupWizardCredentialValues; - runtime: ChannelOnboardingConfigureContext["runtime"]; + runtime: ChannelSetupConfigureContext["runtime"]; prompter: WizardPrompter; - options?: ChannelOnboardingConfigureContext["options"]; + options?: ChannelSetupConfigureContext["options"]; }) => | { cfg?: OpenClawConfig; @@ -229,9 +229,9 @@ export type ChannelSetupWizardFinalize = (params: { cfg: OpenClawConfig; accountId: string; credentialValues: ChannelSetupWizardCredentialValues; - runtime: ChannelOnboardingConfigureContext["runtime"]; + runtime: ChannelSetupConfigureContext["runtime"]; prompter: WizardPrompter; - options?: ChannelOnboardingConfigureContext["options"]; + options?: ChannelSetupConfigureContext["options"]; forceAllowFrom: boolean; }) => | { @@ -252,7 +252,7 @@ export type ChannelSetupWizard = { resolveAccountIdForConfigure?: (params: { cfg: OpenClawConfig; prompter: WizardPrompter; - options?: ChannelOnboardingConfigureContext["options"]; + options?: ChannelSetupConfigureContext["options"]; accountOverride?: string; shouldPromptAccountIds: boolean; listAccountIds: ChannelSetupWizardPlugin["config"]["listAccountIds"]; @@ -260,7 +260,7 @@ export type ChannelSetupWizard = { }) => string | Promise; resolveShouldPromptAccountIds?: (params: { cfg: OpenClawConfig; - options?: ChannelOnboardingConfigureContext["options"]; + options?: ChannelSetupConfigureContext["options"]; shouldPromptAccountIds: boolean; }) => boolean; prepare?: ChannelSetupWizardPrepare; @@ -269,11 +269,11 @@ export type ChannelSetupWizard = { textInputs?: ChannelSetupWizardTextInput[]; finalize?: ChannelSetupWizardFinalize; completionNote?: ChannelSetupWizardNote; - dmPolicy?: ChannelOnboardingDmPolicy; + dmPolicy?: ChannelSetupDmPolicy; allowFrom?: ChannelSetupWizardAllowFrom; groupAccess?: ChannelSetupWizardGroupAccess; disable?: (cfg: OpenClawConfig) => OpenClawConfig; - onAccountRecorded?: ChannelOnboardingAdapter["onAccountRecorded"]; + onAccountRecorded?: ChannelSetupFlowAdapter["onAccountRecorded"]; }; type ChannelSetupWizardPlugin = Pick; @@ -281,8 +281,8 @@ type ChannelSetupWizardPlugin = Pick { + ctx: ChannelSetupStatusContext, +): Promise { const configured = await wizard.status.resolveConfigured({ cfg: ctx.cfg }); const statusLines = (await wizard.status.resolveStatusLines?.({ cfg: ctx.cfg, @@ -399,10 +399,10 @@ async function applyWizardTextInputValue(params: { }).cfg; } -export function buildChannelOnboardingAdapterFromSetupWizard(params: { +export function buildChannelSetupFlowAdapterFromSetupWizard(params: { plugin: ChannelSetupWizardPlugin; wizard: ChannelSetupWizard; -}): ChannelOnboardingAdapter { +}): ChannelSetupFlowAdapter { const { plugin, wizard } = params; return { channel: plugin.id, @@ -809,7 +809,7 @@ export function buildChannelOnboardingAdapterFromSetupWizard(params: { message: allowFrom.message, placeholder: allowFrom.placeholder, label: allowFrom.helpTitle ?? `${plugin.meta.label} allowlist`, - parseInputs: allowFrom.parseInputs ?? splitOnboardingEntries, + parseInputs: allowFrom.parseInputs ?? splitSetupEntries, parseId: allowFrom.parseId, invalidWithoutTokenNote: allowFrom.invalidWithoutCredentialNote, resolveEntries: async ({ entries }) => diff --git a/src/commands/channel-setup/types.ts b/src/commands/channel-setup/types.ts new file mode 100644 index 00000000000..f610d0cb1f6 --- /dev/null +++ b/src/commands/channel-setup/types.ts @@ -0,0 +1 @@ +export * from "../../channels/plugins/setup-flow-types.js"; diff --git a/src/commands/onboarding/types.ts b/src/commands/onboarding/types.ts deleted file mode 100644 index fb0430abda0..00000000000 --- a/src/commands/onboarding/types.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../channels/plugins/onboarding-types.js"; diff --git a/src/plugin-sdk/bluebubbles.ts b/src/plugin-sdk/bluebubbles.ts index 4527f24917d..3c8fc8c194c 100644 --- a/src/plugin-sdk/bluebubbles.ts +++ b/src/plugin-sdk/bluebubbles.ts @@ -35,7 +35,7 @@ export { addWildcardAllowFrom, mergeAllowFromEntries, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export { applyAccountNameToChannelSection, diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 246185f404e..03c48b7e414 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -21,8 +21,8 @@ export { setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, - splitOnboardingEntries, -} from "../channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../channels/plugins/setup-flow-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export type { BaseProbeResult, diff --git a/src/plugin-sdk/googlechat.ts b/src/plugin-sdk/googlechat.ts index 42ad2eb032f..130b3d2fc14 100644 --- a/src/plugin-sdk/googlechat.ts +++ b/src/plugin-sdk/googlechat.ts @@ -27,9 +27,9 @@ export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js export { addWildcardAllowFrom, mergeAllowFromEntries, - splitOnboardingEntries, + splitSetupEntries, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export { applyAccountNameToChannelSection, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 699d0778522..ba5583d2c4a 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -229,7 +229,7 @@ export { export { promptSingleChannelSecretInput, type SingleChannelSecretInputPromptResult, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; export { buildChannelSendResult } from "./channel-send-result.js"; diff --git a/src/plugin-sdk/irc.ts b/src/plugin-sdk/irc.ts index c74aab071ca..2b2a86badda 100644 --- a/src/plugin-sdk/irc.ts +++ b/src/plugin-sdk/irc.ts @@ -17,7 +17,7 @@ export { addWildcardAllowFrom, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export { patchScopedAccountConfig } from "../channels/plugins/setup-helpers.js"; export type { BaseProbeResult } from "../channels/plugins/types.js"; diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index 8a62aa9ae10..58234ca86fe 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -38,7 +38,7 @@ export { mergeAllowFromEntries, promptSingleChannelSecretInput, setTopLevelChannelGroupPolicy, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export { applyAccountNameToChannelSection } from "../channels/plugins/setup-helpers.js"; export { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; diff --git a/src/plugin-sdk/mattermost.ts b/src/plugin-sdk/mattermost.ts index 6cfeeacd918..4787d5e8ac3 100644 --- a/src/plugin-sdk/mattermost.ts +++ b/src/plugin-sdk/mattermost.ts @@ -32,7 +32,7 @@ export { buildSingleChannelSecretPromptState, promptSingleChannelSecretInput, runSingleChannelSecretStep, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { applyAccountNameToChannelSection, applySetupAccountConfigPatch, diff --git a/src/plugin-sdk/msteams.ts b/src/plugin-sdk/msteams.ts index 2f5a91d8989..96e296af04a 100644 --- a/src/plugin-sdk/msteams.ts +++ b/src/plugin-sdk/msteams.ts @@ -38,8 +38,8 @@ export { setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, - splitOnboardingEntries, -} from "../channels/plugins/onboarding/helpers.js"; + splitSetupEntries, +} from "../channels/plugins/setup-flow-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export type { BaseProbeResult, diff --git a/src/plugin-sdk/nextcloud-talk.ts b/src/plugin-sdk/nextcloud-talk.ts index f0d2e1de29d..960ac32af0b 100644 --- a/src/plugin-sdk/nextcloud-talk.ts +++ b/src/plugin-sdk/nextcloud-talk.ts @@ -24,7 +24,7 @@ export { promptSingleChannelSecretInput, runSingleChannelSecretStep, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { applyAccountNameToChannelSection, patchScopedAccountConfig, diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index 9f680ce6b0e..775f2817ca1 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -18,7 +18,7 @@ export { promptSingleChannelSecretInput, runSingleChannelSecretStep, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export { applyAccountNameToChannelSection, diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index 5dba9c0aa77..9e4910b1c85 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -15,7 +15,7 @@ export { addWildcardAllowFrom, mergeAllowFromEntries, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../channels/plugins/onboarding/helpers.js"; +} from "../channels/plugins/setup-flow-helpers.js"; export { applyAccountNameToChannelSection, applySetupAccountConfigPatch, From de503dbcbbd82e21a2c2630ca421fbba78820aec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:39:29 -0700 Subject: [PATCH 162/943] refactor: move setup fallback into setup registry --- extensions/line/setup-entry.ts | 5 ++ extensions/line/src/channel.setup.ts | 69 +++++++++++++++++ src/channels/plugins/setup-registry.ts | 48 +++++++++--- src/commands/channel-setup/registry.ts | 94 ++++------------------- src/commands/channel-test-helpers.ts | 28 +++---- src/commands/channels/add.ts | 2 +- src/commands/onboard-channels.e2e.test.ts | 14 ++-- src/commands/onboard-channels.ts | 87 ++++++++++----------- 8 files changed, 187 insertions(+), 160 deletions(-) create mode 100644 extensions/line/setup-entry.ts create mode 100644 extensions/line/src/channel.setup.ts diff --git a/extensions/line/setup-entry.ts b/extensions/line/setup-entry.ts new file mode 100644 index 00000000000..ca25d243155 --- /dev/null +++ b/extensions/line/setup-entry.ts @@ -0,0 +1,5 @@ +import { lineSetupPlugin } from "./src/channel.setup.js"; + +export default { + plugin: lineSetupPlugin, +}; diff --git a/extensions/line/src/channel.setup.ts b/extensions/line/src/channel.setup.ts new file mode 100644 index 00000000000..71a1d87c45d --- /dev/null +++ b/extensions/line/src/channel.setup.ts @@ -0,0 +1,69 @@ +import { + buildChannelConfigSchema, + LineConfigSchema, + type ChannelPlugin, + type OpenClawConfig, + type ResolvedLineAccount, +} from "openclaw/plugin-sdk/line"; +import { + listLineAccountIds, + resolveDefaultLineAccountId, + resolveLineAccount, +} from "../../../src/line/accounts.js"; +import { lineSetupAdapter } from "./setup-core.js"; +import { lineSetupWizard } from "./setup-surface.js"; + +const meta = { + id: "line", + label: "LINE", + selectionLabel: "LINE (Messaging API)", + detailLabel: "LINE Bot", + docsPath: "/channels/line", + docsLabel: "line", + blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.", + systemImage: "message.fill", +} as const; + +const normalizeLineAllowFrom = (entry: string) => entry.replace(/^line:(?:user:)?/i, ""); + +export const lineSetupPlugin: ChannelPlugin = { + id: "line", + meta: { + ...meta, + quickstartAllowFrom: true, + }, + capabilities: { + chatTypes: ["direct", "group"], + reactions: false, + threads: false, + media: true, + nativeCommands: false, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.line"] }, + configSchema: buildChannelConfigSchema(LineConfigSchema), + config: { + listAccountIds: (cfg: OpenClawConfig) => listLineAccountIds(cfg), + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => + resolveLineAccount({ cfg, accountId: accountId ?? undefined }), + defaultAccountId: (cfg: OpenClawConfig) => resolveDefaultLineAccountId(cfg), + isConfigured: (account) => + Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), + tokenSource: account.tokenSource ?? undefined, + }), + resolveAllowFrom: ({ cfg, accountId }) => + resolveLineAccount({ cfg, accountId: accountId ?? undefined }).config.allowFrom, + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => normalizeLineAllowFrom(entry)), + }, + setupWizard: lineSetupWizard, + setup: lineSetupAdapter, +}; diff --git a/src/channels/plugins/setup-registry.ts b/src/channels/plugins/setup-registry.ts index 493b14351cc..a8c7212ca1f 100644 --- a/src/channels/plugins/setup-registry.ts +++ b/src/channels/plugins/setup-registry.ts @@ -1,3 +1,12 @@ +import { discordSetupPlugin } from "../../../extensions/discord/src/channel.setup.js"; +import { googlechatPlugin } from "../../../extensions/googlechat/src/channel.js"; +import { imessageSetupPlugin } from "../../../extensions/imessage/src/channel.setup.js"; +import { ircPlugin } from "../../../extensions/irc/src/channel.js"; +import { lineSetupPlugin } from "../../../extensions/line/src/channel.setup.js"; +import { signalSetupPlugin } from "../../../extensions/signal/src/channel.setup.js"; +import { slackSetupPlugin } from "../../../extensions/slack/src/channel.setup.js"; +import { telegramSetupPlugin } from "../../../extensions/telegram/src/channel.setup.js"; +import { whatsappSetupPlugin } from "../../../extensions/whatsapp/src/channel.setup.js"; import { getActivePluginRegistryVersion, requireActivePluginRegistry, @@ -19,6 +28,18 @@ const EMPTY_CHANNEL_SETUP_CACHE: CachedChannelSetupPlugins = { let cachedChannelSetupPlugins = EMPTY_CHANNEL_SETUP_CACHE; +const BUNDLED_CHANNEL_SETUP_PLUGINS = [ + telegramSetupPlugin, + whatsappSetupPlugin, + discordSetupPlugin, + ircPlugin, + googlechatPlugin, + slackSetupPlugin, + signalSetupPlugin, + imessageSetupPlugin, + lineSetupPlugin, +] as ChannelPlugin[]; + function dedupeSetupPlugins(plugins: ChannelPlugin[]): ChannelPlugin[] { const seen = new Set(); const resolved: ChannelPlugin[] = []; @@ -33,17 +54,8 @@ function dedupeSetupPlugins(plugins: ChannelPlugin[]): ChannelPlugin[] { return resolved; } -function resolveCachedChannelSetupPlugins(): CachedChannelSetupPlugins { - const registry = requireActivePluginRegistry(); - const registryVersion = getActivePluginRegistryVersion(); - const cached = cachedChannelSetupPlugins; - if (cached.registryVersion === registryVersion) { - return cached; - } - - const sorted = dedupeSetupPlugins( - (registry.channelSetups ?? []).map((entry) => entry.plugin), - ).toSorted((a, b) => { +function sortChannelSetupPlugins(plugins: ChannelPlugin[]): ChannelPlugin[] { + return dedupeSetupPlugins(plugins).toSorted((a, b) => { const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId); const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId); const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA); @@ -53,6 +65,20 @@ function resolveCachedChannelSetupPlugins(): CachedChannelSetupPlugins { } return a.id.localeCompare(b.id); }); +} + +function resolveCachedChannelSetupPlugins(): CachedChannelSetupPlugins { + const registry = requireActivePluginRegistry(); + const registryVersion = getActivePluginRegistryVersion(); + const cached = cachedChannelSetupPlugins; + if (cached.registryVersion === registryVersion) { + return cached; + } + + const registryPlugins = (registry.channelSetups ?? []).map((entry) => entry.plugin); + const sorted = sortChannelSetupPlugins( + registryPlugins.length > 0 ? registryPlugins : BUNDLED_CHANNEL_SETUP_PLUGINS, + ); const byId = new Map(); for (const plugin of sorted) { byId.set(plugin.id, plugin); diff --git a/src/commands/channel-setup/registry.ts b/src/commands/channel-setup/registry.ts index bedc2f9bf6d..9bfd1cf188b 100644 --- a/src/commands/channel-setup/registry.ts +++ b/src/commands/channel-setup/registry.ts @@ -1,46 +1,20 @@ -import { discordPlugin } from "../../../extensions/discord/src/channel.js"; -import { googlechatPlugin } from "../../../extensions/googlechat/src/channel.js"; -import { imessagePlugin } from "../../../extensions/imessage/src/channel.js"; -import { ircPlugin } from "../../../extensions/irc/src/channel.js"; -import { linePlugin } from "../../../extensions/line/src/channel.js"; -import { signalPlugin } from "../../../extensions/signal/src/channel.js"; -import { slackPlugin } from "../../../extensions/slack/src/channel.js"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; -import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js"; import { listChannelSetupPlugins } from "../../channels/plugins/setup-registry.js"; -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../channels/plugins/setup-wizard.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { ChannelChoice } from "../onboard-types.js"; -import type { ChannelOnboardingAdapter } from "../onboarding/types.js"; +import type { ChannelSetupFlowAdapter } from "./types.js"; -const EMPTY_REGISTRY_FALLBACK_PLUGINS = [ - telegramPlugin, - whatsappPlugin, - discordPlugin, - ircPlugin, - googlechatPlugin, - slackPlugin, - signalPlugin, - imessagePlugin, - linePlugin, -]; +const setupWizardAdapters = new WeakMap(); -export type ChannelOnboardingSetupPlugin = Pick< - ChannelPlugin, - "id" | "meta" | "capabilities" | "config" | "setup" | "setupWizard" ->; - -const setupWizardAdapters = new WeakMap(); - -export function resolveChannelOnboardingAdapterForPlugin( - plugin?: ChannelOnboardingSetupPlugin, -): ChannelOnboardingAdapter | undefined { +export function resolveChannelSetupFlowAdapterForPlugin( + plugin?: ChannelPlugin, +): ChannelSetupFlowAdapter | undefined { if (plugin?.setupWizard) { const cached = setupWizardAdapters.get(plugin); if (cached) { return cached; } - const adapter = buildChannelOnboardingAdapterFromSetupWizard({ + const adapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin, wizard: plugin.setupWizard, }); @@ -50,15 +24,10 @@ export function resolveChannelOnboardingAdapterForPlugin( return undefined; } -const CHANNEL_ONBOARDING_ADAPTERS = () => { - const adapters = new Map(); - const setupPlugins = listChannelSetupPlugins(); - const plugins = - setupPlugins.length > 0 - ? setupPlugins - : (EMPTY_REGISTRY_FALLBACK_PLUGINS as unknown as ReturnType); - for (const plugin of plugins) { - const adapter = resolveChannelOnboardingAdapterForPlugin(plugin); +const CHANNEL_SETUP_FLOW_ADAPTERS = () => { + const adapters = new Map(); + for (const plugin of listChannelSetupPlugins()) { + const adapter = resolveChannelSetupFlowAdapterForPlugin(plugin); if (!adapter) { continue; } @@ -67,43 +36,12 @@ const CHANNEL_ONBOARDING_ADAPTERS = () => { return adapters; }; -export function getChannelOnboardingAdapter( +export function getChannelSetupFlowAdapter( channel: ChannelChoice, -): ChannelOnboardingAdapter | undefined { - return CHANNEL_ONBOARDING_ADAPTERS().get(channel); +): ChannelSetupFlowAdapter | undefined { + return CHANNEL_SETUP_FLOW_ADAPTERS().get(channel); } -export function listChannelOnboardingAdapters(): ChannelOnboardingAdapter[] { - return Array.from(CHANNEL_ONBOARDING_ADAPTERS().values()); +export function listChannelSetupFlowAdapters(): ChannelSetupFlowAdapter[] { + return Array.from(CHANNEL_SETUP_FLOW_ADAPTERS().values()); } - -export async function loadBundledChannelOnboardingPlugin( - channel: ChannelChoice, -): Promise { - switch (channel) { - case "discord": - return discordPlugin as ChannelPlugin; - case "googlechat": - return googlechatPlugin as ChannelPlugin; - case "imessage": - return imessagePlugin as ChannelPlugin; - case "irc": - return ircPlugin as ChannelPlugin; - case "line": - return linePlugin as ChannelPlugin; - case "signal": - return signalPlugin as ChannelPlugin; - case "slack": - return slackPlugin as ChannelPlugin; - case "telegram": - return telegramPlugin as ChannelPlugin; - case "whatsapp": - return whatsappPlugin as ChannelPlugin; - default: - return undefined; - } -} - -// Legacy aliases (pre-rename). -export const getProviderOnboardingAdapter = getChannelOnboardingAdapter; -export const listProviderOnboardingAdapters = listChannelOnboardingAdapters; diff --git a/src/commands/channel-test-helpers.ts b/src/commands/channel-test-helpers.ts index 97167228e7f..7a6d687a91c 100644 --- a/src/commands/channel-test-helpers.ts +++ b/src/commands/channel-test-helpers.ts @@ -6,22 +6,22 @@ import { telegramPlugin } from "../../extensions/telegram/src/channel.js"; import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; -import { getChannelOnboardingAdapter } from "./channel-setup/registry.js"; +import { getChannelSetupFlowAdapter } from "./channel-setup/registry.js"; +import type { ChannelSetupFlowAdapter } from "./channel-setup/types.js"; import type { ChannelChoice } from "./onboard-types.js"; -import type { ChannelOnboardingAdapter } from "./onboarding/types.js"; -type ChannelOnboardingAdapterPatch = Partial< +type ChannelSetupFlowAdapterPatch = Partial< Pick< - ChannelOnboardingAdapter, + ChannelSetupFlowAdapter, "configure" | "configureInteractive" | "configureWhenConfigured" | "getStatus" > >; -type PatchedOnboardingAdapterFields = { - configure?: ChannelOnboardingAdapter["configure"]; - configureInteractive?: ChannelOnboardingAdapter["configureInteractive"]; - configureWhenConfigured?: ChannelOnboardingAdapter["configureWhenConfigured"]; - getStatus?: ChannelOnboardingAdapter["getStatus"]; +type PatchedSetupAdapterFields = { + configure?: ChannelSetupFlowAdapter["configure"]; + configureInteractive?: ChannelSetupFlowAdapter["configureInteractive"]; + configureWhenConfigured?: ChannelSetupFlowAdapter["configureWhenConfigured"]; + getStatus?: ChannelSetupFlowAdapter["getStatus"]; }; export function setDefaultChannelPluginRegistryForTests(): void { @@ -36,16 +36,16 @@ export function setDefaultChannelPluginRegistryForTests(): void { setActivePluginRegistry(createTestRegistry(channels)); } -export function patchChannelOnboardingAdapter( +export function patchChannelSetupFlowAdapter( channel: ChannelChoice, - patch: ChannelOnboardingAdapterPatch, + patch: ChannelSetupFlowAdapterPatch, ): () => void { - const adapter = getChannelOnboardingAdapter(channel); + const adapter = getChannelSetupFlowAdapter(channel); if (!adapter) { - throw new Error(`missing onboarding adapter for ${channel}`); + throw new Error(`missing setup adapter for ${channel}`); } - const previous: PatchedOnboardingAdapterFields = {}; + const previous: PatchedSetupAdapterFields = {}; if (Object.prototype.hasOwnProperty.call(patch, "getStatus")) { previous.getStatus = adapter.getStatus; diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 30fe44f1b54..d4175cf100b 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -2,7 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/ag import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js"; import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; -import type { ChannelOnboardingSetupPlugin } from "../../channels/plugins/onboarding-types.js"; +import type { ChannelOnboardingSetupPlugin } from "../../channels/plugins/setup-flow-types.js"; import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js"; import type { ChannelId, ChannelPlugin, ChannelSetupInput } from "../../channels/plugins/types.js"; import { writeConfigFile, type OpenClawConfig } from "../../config/config.js"; diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index 0f2fb4c2e1e..faf1e7cfb7e 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -5,7 +5,7 @@ import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { - patchChannelOnboardingAdapter, + patchChannelSetupFlowAdapter, setDefaultChannelPluginRegistryForTests, } from "./channel-test-helpers.js"; import { setupChannels } from "./onboard-channels.js"; @@ -96,8 +96,8 @@ function createTelegramCfg(botToken: string, enabled?: boolean): OpenClawConfig } as OpenClawConfig; } -function patchTelegramAdapter(overrides: Parameters[1]) { - return patchChannelOnboardingAdapter("telegram", { +function patchTelegramAdapter(overrides: Parameters[1]) { + return patchChannelSetupFlowAdapter("telegram", { ...overrides, getStatus: overrides.getStatus ?? @@ -277,7 +277,7 @@ describe("setupChannels", () => { expect(multiselect).not.toHaveBeenCalled(); }); - it("continues Telegram onboarding even when plugin registry is empty (avoids 'plugin not available' block)", async () => { + it("continues Telegram setup when the plugin registry is empty", async () => { // Simulate missing registry entries (the scenario reported in #25545). setActivePluginRegistry(createEmptyPluginRegistry()); // Avoid accidental env-token configuration changing the prompt path. @@ -311,11 +311,7 @@ describe("setupChannels", () => { ); }); expect(sawHardStop).toBe(false); - expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( - expect.objectContaining({ - channel: "telegram", - }), - ); + expect(loadOnboardingPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); expect(reloadOnboardingPluginRegistry).not.toHaveBeenCalled(); }); diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index ffc4932f7b8..67c78e7a72c 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -1,7 +1,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; -import type { ChannelOnboardingSetupPlugin } from "../channels/plugins/onboarding-types.js"; +import type { ChannelOnboardingSetupPlugin } from "../channels/plugins/setup-flow-types.js"; import { getChannelSetupPlugin, listChannelSetupPlugins, @@ -21,23 +21,20 @@ import type { RuntimeEnv } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; import { resolveChannelSetupEntries } from "./channel-setup/discovery.js"; -import { - loadBundledChannelOnboardingPlugin, - resolveChannelOnboardingAdapterForPlugin, -} from "./channel-setup/registry.js"; +import { resolveChannelSetupFlowAdapterForPlugin } from "./channel-setup/registry.js"; +import type { + ChannelSetupFlowAdapter, + ChannelSetupConfiguredResult, + ChannelSetupDmPolicy, + ChannelSetupResult, + ChannelSetupStatus, + SetupChannelsOptions, +} from "./channel-setup/types.js"; import type { ChannelChoice } from "./onboard-types.js"; import { ensureOnboardingPluginInstalled, loadOnboardingPluginRegistrySnapshotForChannel, } from "./onboarding/plugin-install.js"; -import type { - ChannelOnboardingAdapter, - ChannelOnboardingConfiguredResult, - ChannelOnboardingDmPolicy, - ChannelOnboardingResult, - ChannelOnboardingStatus, - SetupChannelsOptions, -} from "./onboarding/types.js"; type ConfiguredChannelAction = "update" | "disable" | "delete" | "skip"; @@ -45,7 +42,7 @@ type ChannelStatusSummary = { installedPlugins: ReturnType; catalogEntries: ReturnType; installedCatalogEntries: ReturnType; - statusByChannel: Map; + statusByChannel: Map; statusLines: string[]; }; @@ -122,7 +119,7 @@ async function collectChannelStatus(params: { options?: SetupChannelsOptions; accountOverrides: Partial>; installedPlugins?: ChannelOnboardingSetupPlugin[]; - resolveAdapter?: (channel: ChannelChoice) => ChannelOnboardingAdapter | undefined; + resolveAdapter?: (channel: ChannelChoice) => ChannelSetupFlowAdapter | undefined; }): Promise { const installedPlugins = params.installedPlugins ?? listChannelSetupPlugins(); const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); @@ -134,7 +131,7 @@ async function collectChannelStatus(params: { const resolveAdapter = params.resolveAdapter ?? ((channel: ChannelChoice) => - resolveChannelOnboardingAdapterForPlugin( + resolveChannelSetupFlowAdapterForPlugin( installedPlugins.find((plugin) => plugin.id === channel), )); const statusEntries = await Promise.all( @@ -274,13 +271,13 @@ async function maybeConfigureDmPolicies(params: { selection: ChannelChoice[]; prompter: WizardPrompter; accountIdsByChannel?: Map; - resolveAdapter?: (channel: ChannelChoice) => ChannelOnboardingAdapter | undefined; + resolveAdapter?: (channel: ChannelChoice) => ChannelSetupFlowAdapter | undefined; }): Promise { const { selection, prompter, accountIdsByChannel } = params; const resolve = params.resolveAdapter ?? (() => undefined); const dmPolicies = selection - .map((channel) => resolve?.(channel)?.dmPolicy) - .filter(Boolean) as ChannelOnboardingDmPolicy[]; + .map((channel) => resolve(channel)?.dmPolicy) + .filter(Boolean) as ChannelSetupDmPolicy[]; if (dmPolicies.length === 0) { return params.cfg; } @@ -294,7 +291,7 @@ async function maybeConfigureDmPolicies(params: { } let cfg = params.cfg; - const selectPolicy = async (policy: ChannelOnboardingDmPolicy) => { + const selectPolicy = async (policy: ChannelSetupDmPolicy) => { await prompter.note( [ "Default: pairing (unknown DMs get a pairing code).", @@ -337,7 +334,7 @@ async function maybeConfigureDmPolicies(params: { return cfg; } -// Channel-specific prompts moved into onboarding adapters. +// Channel-specific prompts moved into setup flow adapters. export async function setupChannels( cfg: OpenClawConfig, @@ -393,21 +390,17 @@ export async function setupChannels( rememberScopedPlugin(plugin); return plugin; } - const bundledPlugin = await loadBundledChannelOnboardingPlugin(channel); - if (bundledPlugin) { - rememberScopedPlugin(bundledPlugin); - } - return bundledPlugin; + return undefined; }; - const getVisibleOnboardingAdapter = (channel: ChannelChoice) => { + const getVisibleSetupFlowAdapter = (channel: ChannelChoice) => { const scopedPlugin = scopedPluginsById.get(channel); if (scopedPlugin) { - return resolveChannelOnboardingAdapterForPlugin(scopedPlugin); + return resolveChannelSetupFlowAdapterForPlugin(scopedPlugin); } - return resolveChannelOnboardingAdapterForPlugin(getChannelSetupPlugin(channel)); + return resolveChannelSetupFlowAdapterForPlugin(getChannelSetupPlugin(channel)); }; const preloadConfiguredExternalPlugins = () => { - // Keep onboarding memory bounded by snapshot-loading only configured external plugins. + // Keep setup memory bounded by snapshot-loading only configured external plugins. const workspaceDir = resolveWorkspaceDir(); for (const entry of listChannelPluginCatalogEntries({ workspaceDir })) { const channel = entry.id as ChannelChoice; @@ -438,7 +431,7 @@ export async function setupChannels( options, accountOverrides, installedPlugins: listVisibleInstalledPlugins(), - resolveAdapter: getVisibleOnboardingAdapter, + resolveAdapter: getVisibleSetupFlowAdapter, }); if (!options?.skipStatusNote && statusLines.length > 0) { await prompter.note(statusLines.join("\n"), "Channel status"); @@ -493,7 +486,7 @@ export async function setupChannels( const accountIdsByChannel = new Map(); const recordAccount = (channel: ChannelChoice, accountId: string) => { options?.onAccountId?.(channel, accountId); - const adapter = getVisibleOnboardingAdapter(channel); + const adapter = getVisibleSetupFlowAdapter(channel); adapter?.onAccountRecorded?.(accountId, options); accountIdsByChannel.set(channel, accountId); }; @@ -566,7 +559,7 @@ export async function setupChannels( }; const refreshStatus = async (channel: ChannelChoice) => { - const adapter = getVisibleOnboardingAdapter(channel); + const adapter = getVisibleSetupFlowAdapter(channel); if (!adapter) { return; } @@ -589,11 +582,11 @@ export async function setupChannels( return false; } const plugin = await loadScopedChannelPlugin(channel); - const adapter = getVisibleOnboardingAdapter(channel); + const adapter = getVisibleSetupFlowAdapter(channel); if (!plugin) { if (adapter) { await prompter.note( - `${channel} plugin not available (continuing with onboarding). If the channel still doesn't work after setup, run \`${formatCliCommand( + `${channel} plugin not available (continuing with setup). If the channel still doesn't work after setup, run \`${formatCliCommand( "openclaw plugins list", )}\` and \`${formatCliCommand("openclaw plugins enable " + channel)}\`, then restart the gateway.`, "Channel setup", @@ -608,7 +601,7 @@ export async function setupChannels( return true; }; - const applyOnboardingResult = async (channel: ChannelChoice, result: ChannelOnboardingResult) => { + const applySetupResult = async (channel: ChannelChoice, result: ChannelSetupResult) => { next = result.cfg; if (result.accountId) { recordAccount(channel, result.accountId); @@ -617,21 +610,21 @@ export async function setupChannels( await refreshStatus(channel); }; - const applyCustomOnboardingResult = async ( + const applyCustomSetupResult = async ( channel: ChannelChoice, - result: ChannelOnboardingConfiguredResult, + result: ChannelSetupConfiguredResult, ) => { if (result === "skip") { return false; } - await applyOnboardingResult(channel, result); + await applySetupResult(channel, result); return true; }; const configureChannel = async (channel: ChannelChoice) => { - const adapter = getVisibleOnboardingAdapter(channel); + const adapter = getVisibleSetupFlowAdapter(channel); if (!adapter) { - await prompter.note(`${channel} does not support onboarding yet.`, "Channel setup"); + await prompter.note(`${channel} does not support guided setup yet.`, "Channel setup"); return; } const result = await adapter.configure({ @@ -643,12 +636,12 @@ export async function setupChannels( shouldPromptAccountIds, forceAllowFrom: forceAllowFromChannels.has(channel), }); - await applyOnboardingResult(channel, result); + await applySetupResult(channel, result); }; const handleConfiguredChannel = async (channel: ChannelChoice, label: string) => { const plugin = getVisibleChannelPlugin(channel); - const adapter = getVisibleOnboardingAdapter(channel); + const adapter = getVisibleSetupFlowAdapter(channel); if (adapter?.configureWhenConfigured) { const custom = await adapter.configureWhenConfigured({ cfg: next, @@ -661,7 +654,7 @@ export async function setupChannels( configured: true, label, }); - if (!(await applyCustomOnboardingResult(channel, custom))) { + if (!(await applyCustomSetupResult(channel, custom))) { return; } return; @@ -772,7 +765,7 @@ export async function setupChannels( } const plugin = getVisibleChannelPlugin(channel); - const adapter = getVisibleOnboardingAdapter(channel); + const adapter = getVisibleSetupFlowAdapter(channel); const label = plugin?.meta.label ?? catalogEntry?.meta.label ?? channel; const status = statusByChannel.get(channel); const configured = status?.configured ?? false; @@ -788,7 +781,7 @@ export async function setupChannels( configured, label, }); - if (!(await applyCustomOnboardingResult(channel, custom))) { + if (!(await applyCustomSetupResult(channel, custom))) { return; } return; @@ -861,7 +854,7 @@ export async function setupChannels( selection, prompter, accountIdsByChannel, - resolveAdapter: getVisibleOnboardingAdapter, + resolveAdapter: getVisibleSetupFlowAdapter, }); } From 371366e9eb6b0a8528d5c5a1362d950575a99a94 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:39:37 -0700 Subject: [PATCH 163/943] feat: add synology chat setup wizard --- extensions/synology-chat/index.ts | 4 +- extensions/synology-chat/package.json | 1 + extensions/synology-chat/setup-entry.ts | 5 + extensions/synology-chat/src/channel.test.ts | 2 + extensions/synology-chat/src/channel.ts | 5 + .../synology-chat/src/setup-surface.test.ts | 101 ++++++ extensions/synology-chat/src/setup-surface.ts | 324 ++++++++++++++++++ src/plugin-sdk/subpaths.test.ts | 6 + src/plugin-sdk/synology-chat.ts | 6 + 9 files changed, 452 insertions(+), 2 deletions(-) create mode 100644 extensions/synology-chat/setup-entry.ts create mode 100644 extensions/synology-chat/src/setup-surface.test.ts create mode 100644 extensions/synology-chat/src/setup-surface.ts diff --git a/extensions/synology-chat/index.ts b/extensions/synology-chat/index.ts index 69dbfb9edbf..9078b9f86c7 100644 --- a/extensions/synology-chat/index.ts +++ b/extensions/synology-chat/index.ts @@ -1,6 +1,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/synology-chat"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/synology-chat"; -import { createSynologyChatPlugin } from "./src/channel.js"; +import { synologyChatPlugin } from "./src/channel.js"; import { setSynologyRuntime } from "./src/runtime.js"; const plugin = { @@ -10,7 +10,7 @@ const plugin = { configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { setSynologyRuntime(api.runtime); - api.registerChannel({ plugin: createSynologyChatPlugin() }); + api.registerChannel({ plugin: synologyChatPlugin }); }, }; diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index c6148c856a3..d8ff22d6361 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -10,6 +10,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "synology-chat", "label": "Synology Chat", diff --git a/extensions/synology-chat/setup-entry.ts b/extensions/synology-chat/setup-entry.ts new file mode 100644 index 00000000000..45cc966e082 --- /dev/null +++ b/extensions/synology-chat/setup-entry.ts @@ -0,0 +1,5 @@ +import { synologyChatPlugin } from "./src/channel.js"; + +export default { + plugin: synologyChatPlugin, +}; diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index bdce5f37d79..b45f8c355e4 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -22,6 +22,8 @@ describe("createSynologyChatPlugin", () => { expect(plugin.meta).toBeDefined(); expect(plugin.capabilities).toBeDefined(); expect(plugin.config).toBeDefined(); + expect(plugin.setup).toBeDefined(); + expect(plugin.setupWizard).toBeDefined(); expect(plugin.security).toBeDefined(); expect(plugin.outbound).toBeDefined(); expect(plugin.gateway).toBeDefined(); diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index d84516dbda5..0bc771a7d26 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -14,6 +14,7 @@ import { z } from "zod"; import { listAccountIds, resolveAccount } from "./accounts.js"; import { sendMessage, sendFileUrl } from "./client.js"; import { getSynologyRuntime } from "./runtime.js"; +import { synologyChatSetupAdapter, synologyChatSetupWizard } from "./setup-surface.js"; import type { ResolvedSynologyChatAccount } from "./types.js"; import { createWebhookHandler } from "./webhook-handler.js"; @@ -68,6 +69,8 @@ export function createSynologyChatPlugin() { reload: { configPrefixes: [`channels.${CHANNEL_ID}`] }, configSchema: SynologyChatConfigSchema, + setup: synologyChatSetupAdapter, + setupWizard: synologyChatSetupWizard, config: { listAccountIds: (cfg: any) => listAccountIds(cfg), @@ -377,3 +380,5 @@ export function createSynologyChatPlugin() { }, }; } + +export const synologyChatPlugin = createSynologyChatPlugin(); diff --git a/extensions/synology-chat/src/setup-surface.test.ts b/extensions/synology-chat/src/setup-surface.test.ts new file mode 100644 index 00000000000..d7a2a1056a0 --- /dev/null +++ b/extensions/synology-chat/src/setup-surface.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { synologyChatPlugin } from "./channel.js"; +import { synologyChatSetupWizard } from "./setup-surface.js"; + +function createPrompter(overrides: Partial = {}): WizardPrompter { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: vi.fn(async ({ options }: { options: Array<{ value: string }> }) => { + const first = options[0]; + if (!first) { + throw new Error("no options"); + } + return first.value; + }) as WizardPrompter["select"], + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} + +const synologyChatConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ + plugin: synologyChatPlugin, + wizard: synologyChatSetupWizard, +}); + +describe("synology-chat setup wizard", () => { + it("configures token and incoming webhook for the default account", async () => { + const prompter = createPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Enter Synology Chat outgoing webhook token") { + return "synology-token"; + } + if (message === "Incoming webhook URL") { + return "https://nas.example.com/webapi/entry.cgi?token=incoming"; + } + if (message === "Outgoing webhook path (optional)") { + return ""; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + }); + + const result = await synologyChatConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime: createRuntimeEnv(), + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.["synology-chat"]?.enabled).toBe(true); + expect(result.cfg.channels?.["synology-chat"]?.token).toBe("synology-token"); + expect(result.cfg.channels?.["synology-chat"]?.incomingUrl).toBe( + "https://nas.example.com/webapi/entry.cgi?token=incoming", + ); + }); + + it("records allowed user ids when setup forces allowFrom", async () => { + const prompter = createPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Enter Synology Chat outgoing webhook token") { + return "synology-token"; + } + if (message === "Incoming webhook URL") { + return "https://nas.example.com/webapi/entry.cgi?token=incoming"; + } + if (message === "Outgoing webhook path (optional)") { + return ""; + } + if (message === "Allowed Synology Chat user ids") { + return "123456, synology-chat:789012"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + }); + + const result = await synologyChatConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime: createRuntimeEnv(), + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: true, + }); + + expect(result.cfg.channels?.["synology-chat"]?.dmPolicy).toBe("allowlist"); + expect(result.cfg.channels?.["synology-chat"]?.allowedUserIds).toEqual(["123456", "789012"]); + }); +}); diff --git a/extensions/synology-chat/src/setup-surface.ts b/extensions/synology-chat/src/setup-surface.ts new file mode 100644 index 00000000000..77ad0ded2c2 --- /dev/null +++ b/extensions/synology-chat/src/setup-surface.ts @@ -0,0 +1,324 @@ +import { + mergeAllowFromEntries, + setSetupChannelEnabled, + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { listAccountIds, resolveAccount } from "./accounts.js"; +import type { SynologyChatAccountRaw, SynologyChatChannelConfig } from "./types.js"; + +const channel = "synology-chat" as const; +const DEFAULT_WEBHOOK_PATH = "/webhook/synology"; + +const SYNOLOGY_SETUP_HELP_LINES = [ + "1) Create an incoming webhook in Synology Chat and copy its URL", + "2) Create an outgoing webhook and copy its secret token", + `3) Point the outgoing webhook to https://${DEFAULT_WEBHOOK_PATH}`, + "4) Keep allowed user IDs handy for DM allowlisting", + `Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`, +]; + +const SYNOLOGY_ALLOW_FROM_HELP_LINES = [ + "Allowlist Synology Chat DMs by numeric user id.", + "Examples:", + "- 123456", + "- synology-chat:123456", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`, +]; + +function getChannelConfig(cfg: OpenClawConfig): SynologyChatChannelConfig { + return (cfg.channels?.[channel] as SynologyChatChannelConfig | undefined) ?? {}; +} + +function getRawAccountConfig(cfg: OpenClawConfig, accountId: string): SynologyChatAccountRaw { + const channelConfig = getChannelConfig(cfg); + if (accountId === DEFAULT_ACCOUNT_ID) { + return channelConfig; + } + return channelConfig.accounts?.[accountId] ?? {}; +} + +function patchSynologyChatAccountConfig(params: { + cfg: OpenClawConfig; + accountId: string; + patch: Record; + clearFields?: string[]; + enabled?: boolean; +}): OpenClawConfig { + const channelConfig = getChannelConfig(params.cfg); + if (params.accountId === DEFAULT_ACCOUNT_ID) { + const nextChannelConfig = { ...channelConfig } as Record; + for (const field of params.clearFields ?? []) { + delete nextChannelConfig[field]; + } + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + [channel]: { + ...nextChannelConfig, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }, + }, + }; + } + + const nextAccounts = { ...(channelConfig.accounts ?? {}) } as Record< + string, + Record + >; + const nextAccountConfig = { ...(nextAccounts[params.accountId] ?? {}) }; + for (const field of params.clearFields ?? []) { + delete nextAccountConfig[field]; + } + nextAccounts[params.accountId] = { + ...nextAccountConfig, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }; + + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + [channel]: { + ...channelConfig, + ...(params.enabled ? { enabled: true } : {}), + accounts: nextAccounts, + }, + }, + }; +} + +function isSynologyChatConfigured(cfg: OpenClawConfig, accountId: string): boolean { + const account = resolveAccount(cfg, accountId); + return Boolean(account.token.trim() && account.incomingUrl.trim()); +} + +function validateWebhookUrl(value: string): string | undefined { + try { + const parsed = new URL(value); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return "Incoming webhook must use http:// or https://."; + } + } catch { + return "Incoming webhook must be a valid URL."; + } + return undefined; +} + +function validateWebhookPath(value: string): string | undefined { + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + return trimmed.startsWith("/") ? undefined : "Webhook path must start with /."; +} + +function parseSynologyUserId(value: string): string | null { + const cleaned = value.replace(/^synology-chat:/i, "").trim(); + return /^\d+$/.test(cleaned) ? cleaned : null; +} + +function resolveExistingAllowedUserIds(cfg: OpenClawConfig, accountId: string): string[] { + const raw = getRawAccountConfig(cfg, accountId).allowedUserIds; + if (Array.isArray(raw)) { + return raw.map((value) => String(value).trim()).filter(Boolean); + } + return String(raw ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); +} + +export const synologyChatSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID, + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "Synology Chat env credentials only support the default account."; + } + if (!input.useEnv && !input.token?.trim()) { + return "Synology Chat requires --token or --use-env."; + } + if (!input.url?.trim()) { + return "Synology Chat requires --url for the incoming webhook."; + } + const urlError = validateWebhookUrl(input.url.trim()); + if (urlError) { + return urlError; + } + if (input.webhookPath?.trim()) { + return validateWebhookPath(input.webhookPath.trim()) ?? null; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: input.useEnv ? ["token"] : undefined, + patch: { + ...(input.useEnv ? {} : { token: input.token?.trim() }), + incomingUrl: input.url?.trim(), + ...(input.webhookPath?.trim() ? { webhookPath: input.webhookPath.trim() } : {}), + }, + }), +}; + +export const synologyChatSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs token + incoming webhook", + configuredHint: "configured", + unconfiguredHint: "needs token + incoming webhook", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => + listAccountIds(cfg).some((accountId) => isSynologyChatConfigured(cfg, accountId)), + resolveStatusLines: ({ cfg, configured }) => [ + `Synology Chat: ${configured ? "configured" : "needs token + incoming webhook"}`, + `Accounts: ${listAccountIds(cfg).length || 0}`, + ], + }, + introNote: { + title: "Synology Chat webhook setup", + lines: SYNOLOGY_SETUP_HELP_LINES, + }, + credentials: [ + { + inputKey: "token", + providerHint: channel, + credentialLabel: "outgoing webhook token", + preferredEnvVar: "SYNOLOGY_CHAT_TOKEN", + helpTitle: "Synology Chat webhook token", + helpLines: SYNOLOGY_SETUP_HELP_LINES, + envPrompt: "SYNOLOGY_CHAT_TOKEN detected. Use env var?", + keepPrompt: "Synology Chat webhook token already configured. Keep it?", + inputPrompt: "Enter Synology Chat outgoing webhook token", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const account = resolveAccount(cfg, accountId); + const raw = getRawAccountConfig(cfg, accountId); + return { + accountConfigured: isSynologyChatConfigured(cfg, accountId), + hasConfiguredValue: Boolean(raw.token?.trim()), + resolvedValue: account.token.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.SYNOLOGY_CHAT_TOKEN?.trim() || undefined + : undefined, + }; + }, + applyUseEnv: async ({ cfg, accountId }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: ["token"], + patch: {}, + }), + applySet: async ({ cfg, accountId, resolvedValue }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + patch: { token: resolvedValue }, + }), + }, + ], + textInputs: [ + { + inputKey: "url", + message: "Incoming webhook URL", + placeholder: + "https://nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming...", + helpTitle: "Synology Chat incoming webhook", + helpLines: [ + "Use the incoming webhook URL from Synology Chat integrations.", + "This is the URL OpenClaw uses to send replies back to Chat.", + ], + currentValue: ({ cfg, accountId }) => getRawAccountConfig(cfg, accountId).incomingUrl?.trim(), + keepPrompt: (value) => `Incoming webhook URL set (${value}). Keep it?`, + validate: ({ value }) => validateWebhookUrl(value), + applySet: async ({ cfg, accountId, value }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + patch: { incomingUrl: value.trim() }, + }), + }, + { + inputKey: "webhookPath", + message: "Outgoing webhook path (optional)", + placeholder: DEFAULT_WEBHOOK_PATH, + required: false, + applyEmptyValue: true, + helpTitle: "Synology Chat outgoing webhook path", + helpLines: [ + `Default path: ${DEFAULT_WEBHOOK_PATH}`, + "Change this only if you need multiple Synology Chat webhook routes.", + ], + currentValue: ({ cfg, accountId }) => getRawAccountConfig(cfg, accountId).webhookPath?.trim(), + keepPrompt: (value) => `Outgoing webhook path set (${value}). Keep it?`, + validate: ({ value }) => validateWebhookPath(value), + applySet: async ({ cfg, accountId, value }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: value.trim() ? undefined : ["webhookPath"], + patch: value.trim() ? { webhookPath: value.trim() } : {}, + }), + }, + ], + allowFrom: { + helpTitle: "Synology Chat allowlist", + helpLines: SYNOLOGY_ALLOW_FROM_HELP_LINES, + message: "Allowed Synology Chat user ids", + placeholder: "123456, 987654", + invalidWithoutCredentialNote: "Synology Chat user ids must be numeric.", + parseInputs: splitSetupEntries, + parseId: parseSynologyUserId, + resolveEntries: async ({ entries }) => + entries.map((entry) => { + const id = parseSynologyUserId(entry); + return { + input: entry, + resolved: Boolean(id), + id, + }; + }), + apply: async ({ cfg, accountId, allowFrom }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + patch: { + dmPolicy: "allowlist", + allowedUserIds: mergeAllowFromEntries( + resolveExistingAllowedUserIds(cfg, accountId), + allowFrom, + ), + }, + }), + }, + completionNote: { + title: "Synology Chat access control", + lines: [ + `Default outgoing webhook path: ${DEFAULT_WEBHOOK_PATH}`, + 'Set allowed user IDs, or manually switch `channels.synology-chat.dmPolicy` to `"open"` for public DMs.', + 'With `dmPolicy="allowlist"`, an empty allowedUserIds list blocks the route from starting.', + `Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`, + ], + }, + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), +}; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index a483e5aaf30..8a57148f430 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -111,6 +111,12 @@ describe("plugin-sdk subpath exports", () => { expect(typeof zaloSdk.zaloSetupAdapter).toBe("object"); }); + it("exports Synology Chat helpers", async () => { + const synologyChatSdk = await import("openclaw/plugin-sdk/synology-chat"); + expect(typeof synologyChatSdk.synologyChatSetupWizard).toBe("object"); + expect(typeof synologyChatSdk.synologyChatSetupAdapter).toBe("object"); + }); + it("exports Zalouser helpers", async () => { const zalouserSdk = await import("openclaw/plugin-sdk/zalouser"); expect(typeof zalouserSdk.zalouserSetupWizard).toBe("object"); diff --git a/src/plugin-sdk/synology-chat.ts b/src/plugin-sdk/synology-chat.ts index dcce2ea760b..f5fae73fbb2 100644 --- a/src/plugin-sdk/synology-chat.ts +++ b/src/plugin-sdk/synology-chat.ts @@ -3,6 +3,7 @@ export { setAccountEnabledInConfigSection } from "../channels/plugins/config-helpers.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; export { isRequestBodyLimitError, readRequestBodyWithLimit, @@ -10,8 +11,13 @@ export { } from "../infra/http-body.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { registerPluginHttpRoute } from "../plugins/http-registry.js"; +export type { OpenClawConfig } from "../config/config.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; export { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; export type { FixedWindowRateLimiter } from "./webhook-memory-guards.js"; export { createFixedWindowRateLimiter } from "./webhook-memory-guards.js"; +export { + synologyChatSetupAdapter, + synologyChatSetupWizard, +} from "../../extensions/synology-chat/src/setup-surface.js"; From 98dcbd3e7eef4c35d48b75b2f3312096e078df9a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:39:47 -0700 Subject: [PATCH 164/943] build: add setup entrypoints for migrated channel plugins --- extensions/line/package.json | 1 + extensions/mattermost/package.json | 1 + extensions/mattermost/setup-entry.ts | 5 +++++ extensions/nostr/package.json | 1 + extensions/nostr/setup-entry.ts | 5 +++++ extensions/zalo/package.json | 1 + extensions/zalo/setup-entry.ts | 5 +++++ extensions/zalouser/package.json | 1 + extensions/zalouser/setup-entry.ts | 5 +++++ 9 files changed, 25 insertions(+) create mode 100644 extensions/mattermost/setup-entry.ts create mode 100644 extensions/nostr/setup-entry.ts create mode 100644 extensions/zalo/setup-entry.ts create mode 100644 extensions/zalouser/setup-entry.ts diff --git a/extensions/line/package.json b/extensions/line/package.json index 85bfac7f0ac..3fa098460d6 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -8,6 +8,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "line", "label": "LINE", diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index 17f8add1b1f..3c414f52f29 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -11,6 +11,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "mattermost", "label": "Mattermost", diff --git a/extensions/mattermost/setup-entry.ts b/extensions/mattermost/setup-entry.ts new file mode 100644 index 00000000000..64c02fcbe9d --- /dev/null +++ b/extensions/mattermost/setup-entry.ts @@ -0,0 +1,5 @@ +import { mattermostPlugin } from "./src/channel.js"; + +export default { + plugin: mattermostPlugin, +}; diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 19ef7cc03e7..991bd54f3d4 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -11,6 +11,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "nostr", "label": "Nostr", diff --git a/extensions/nostr/setup-entry.ts b/extensions/nostr/setup-entry.ts new file mode 100644 index 00000000000..8884a71cc80 --- /dev/null +++ b/extensions/nostr/setup-entry.ts @@ -0,0 +1,5 @@ +import { nostrPlugin } from "./src/channel.js"; + +export default { + plugin: nostrPlugin, +}; diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index a72aabbb29e..b6ab61f7cee 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -11,6 +11,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "zalo", "label": "Zalo", diff --git a/extensions/zalo/setup-entry.ts b/extensions/zalo/setup-entry.ts new file mode 100644 index 00000000000..dd8ca1b70f8 --- /dev/null +++ b/extensions/zalo/setup-entry.ts @@ -0,0 +1,5 @@ +import { zaloPlugin } from "./src/channel.js"; + +export default { + plugin: zaloPlugin, +}; diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index e7c12c9b4b2..5e3a1070237 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -12,6 +12,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "zalouser", "label": "Zalo Personal", diff --git a/extensions/zalouser/setup-entry.ts b/extensions/zalouser/setup-entry.ts new file mode 100644 index 00000000000..f983cad8f80 --- /dev/null +++ b/extensions/zalouser/setup-entry.ts @@ -0,0 +1,5 @@ +import { zalouserPlugin } from "./src/channel.js"; + +export default { + plugin: zalouserPlugin, +}; From dfc237c319788702fb826c48ea0273f5f4ab403d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:39:56 -0700 Subject: [PATCH 165/943] docs: update channel setup docs --- docs/channels/synology-chat.md | 6 +++++- docs/tools/plugin.md | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/channels/synology-chat.md b/docs/channels/synology-chat.md index 89e96b318a3..aae655f27b7 100644 --- a/docs/channels/synology-chat.md +++ b/docs/channels/synology-chat.md @@ -27,13 +27,17 @@ Details: [Plugins](/tools/plugin) ## Quick setup 1. Install and enable the Synology Chat plugin. + - `openclaw onboard` now shows Synology Chat in the same channel setup list as `openclaw channels add`. + - Non-interactive setup: `openclaw channels add --channel synology-chat --token --url ` 2. In Synology Chat integrations: - Create an incoming webhook and copy its URL. - Create an outgoing webhook with your secret token. 3. Point the outgoing webhook URL to your OpenClaw gateway: - `https://gateway-host/webhook/synology` by default. - Or your custom `channels.synology-chat.webhookPath`. -4. Configure `channels.synology-chat` in OpenClaw. +4. Finish setup in OpenClaw. + - Guided: `openclaw onboard` + - Direct: `openclaw channels add --channel synology-chat --token --url ` 5. Restart gateway and send a DM to the Synology Chat bot. Minimal config: diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 976c10d0671..c39401bebfc 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -776,7 +776,7 @@ Security note: `openclaw plugins install` installs plugin dependencies with trees "pure JS/TS" and avoid packages that require `postinstall` builds. Optional: `openclaw.setupEntry` can point at a lightweight setup-only module. -When OpenClaw needs onboarding/setup surfaces for a disabled channel plugin, or +When OpenClaw needs setup surfaces for a disabled channel plugin, or when a channel plugin is enabled but still unconfigured, it loads `setupEntry` instead of the full plugin entry. This keeps startup and onboarding lighter when your main plugin entry also wires tools, hooks, or other runtime-only @@ -784,7 +784,7 @@ code. ### Channel catalog metadata -Channel plugins can advertise onboarding metadata via `openclaw.channel` and +Channel plugins can advertise setup/discovery metadata via `openclaw.channel` and install hints via `openclaw.install`. This keeps the core catalog data-free. Example: @@ -1671,7 +1671,7 @@ Recommended packaging: Publishing contract: - Plugin `package.json` must include `openclaw.extensions` with one or more entry files. -- Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled or still-unconfigured channel onboarding/setup. +- Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled or still-unconfigured channel setup. - Entry files can be `.js` or `.ts` (jiti loads TS at runtime). - `openclaw plugins install ` uses `npm pack`, extracts into `~/.openclaw/extensions//`, and enables it in config. - Config key stability: scoped packages are normalized to the **unscoped** id for `plugins.entries.*`. From 0eaf03f55bbf068e1d9b158c5345431df481c524 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:46:29 -0700 Subject: [PATCH 166/943] fix: update feishu setup adapter import --- extensions/feishu/src/onboarding.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/onboarding.ts index ff8f563cf65..ae247b30f76 100644 --- a/extensions/feishu/src/onboarding.ts +++ b/extensions/feishu/src/onboarding.ts @@ -1,7 +1,7 @@ -import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { feishuPlugin } from "./channel.js"; -export const feishuOnboardingAdapter = buildChannelOnboardingAdapterFromSetupWizard({ +export const feishuOnboardingAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ plugin: feishuPlugin, wizard: feishuPlugin.setupWizard!, }); From 92d53070744feaa0db14fc642cd751591d7178ae Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:49:16 -0700 Subject: [PATCH 167/943] Status: lazy-load channel summary helpers --- src/commands/status.summary.test.ts | 19 +++++++++++++++++ src/commands/status.summary.ts | 33 ++++++++++++++++++++++------- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index addda823a23..c0344065126 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +vi.mock("../channels/config-presence.js", () => ({ + hasPotentialConfiguredChannels: vi.fn(() => true), +})); + vi.mock("../agents/context.js", () => ({ resolveContextTokensForModel: vi.fn(() => 200_000), })); @@ -82,4 +86,19 @@ describe("getStatusSummary", () => { expect(summary.heartbeat.defaultAgentId).toBe("main"); expect(summary.channelSummary).toEqual(["ok"]); }); + + it("skips channel summary imports when no channels are configured", async () => { + const { hasPotentialConfiguredChannels } = await import("../channels/config-presence.js"); + vi.mocked(hasPotentialConfiguredChannels).mockReturnValue(false); + const { buildChannelSummary } = await import("../infra/channel-summary.js"); + const { resolveLinkChannelContext } = await import("./status.link-channel.js"); + const { getStatusSummary } = await import("./status.summary.js"); + + const summary = await getStatusSummary(); + + expect(summary.channelSummary).toEqual([]); + expect(summary.linkChannel).toBeUndefined(); + expect(buildChannelSummary).not.toHaveBeenCalled(); + expect(resolveLinkChannelContext).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index e1347a90b5a..b028c99ab6d 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -16,14 +16,25 @@ import { listAgentsForGateway, resolveSessionModelRef, } from "../gateway/session-utils.js"; -import { buildChannelSummary } from "../infra/channel-summary.js"; import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-runner.js"; import { peekSystemEvents } from "../infra/system-events.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; import { resolveRuntimeServiceVersion } from "../version.js"; -import { resolveLinkChannelContext } from "./status.link-channel.js"; import type { HeartbeatStatus, SessionStatus, StatusSummary } from "./status.types.js"; +let channelSummaryModulePromise: Promise | undefined; +let linkChannelModulePromise: Promise | undefined; + +function loadChannelSummaryModule() { + channelSummaryModulePromise ??= import("../infra/channel-summary.js"); + return channelSummaryModulePromise; +} + +function loadLinkChannelModule() { + linkChannelModulePromise ??= import("./status.link-channel.js"); + return linkChannelModulePromise; +} + const buildFlags = (entry?: SessionEntry): string[] => { if (!entry) { return []; @@ -91,7 +102,11 @@ export async function getStatusSummary( const { includeSensitive = true } = options; const cfg = options.config ?? loadConfig(); const needsChannelPlugins = hasPotentialConfiguredChannels(cfg); - const linkContext = needsChannelPlugins ? await resolveLinkChannelContext(cfg) : null; + const linkContext = needsChannelPlugins + ? await loadLinkChannelModule().then(({ resolveLinkChannelContext }) => + resolveLinkChannelContext(cfg), + ) + : null; const agentList = listAgentsForGateway(cfg); const heartbeatAgents: HeartbeatStatus[] = agentList.agents.map((agent) => { const summary = resolveHeartbeatSummaryForAgent(cfg, agent.id); @@ -103,11 +118,13 @@ export async function getStatusSummary( } satisfies HeartbeatStatus; }); const channelSummary = needsChannelPlugins - ? await buildChannelSummary(cfg, { - colorize: true, - includeAllowFrom: true, - sourceConfig: options.sourceConfig, - }) + ? await loadChannelSummaryModule().then(({ buildChannelSummary }) => + buildChannelSummary(cfg, { + colorize: true, + includeAllowFrom: true, + sourceConfig: options.sourceConfig, + }), + ) : []; const mainSessionKey = resolveMainSessionKey(cfg); const queuedSystemEvents = peekSystemEvents(mainSessionKey); From 1f50fed3b28bf4a87439152c6f2dd50bedcc3db5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:52:09 -0700 Subject: [PATCH 168/943] Agents: skip eager context warmup for status commands --- src/agents/context.lookup.test.ts | 28 ++++++++++++++++++++++++++++ src/agents/context.ts | 2 ++ 2 files changed, 30 insertions(+) diff --git a/src/agents/context.lookup.test.ts b/src/agents/context.lookup.test.ts index e5025b36c76..0f33ada0d1b 100644 --- a/src/agents/context.lookup.test.ts +++ b/src/agents/context.lookup.test.ts @@ -104,6 +104,34 @@ describe("lookupContextTokens", () => { } }); + it("skips eager warmup for status commands that only read model metadata opportunistically", async () => { + const loadConfigMock = vi.fn(() => ({ models: {} })); + mockContextModuleDeps(loadConfigMock); + + const argvSnapshot = process.argv; + process.argv = ["node", "openclaw", "status", "--json"]; + try { + await import("./context.js"); + expect(loadConfigMock).not.toHaveBeenCalled(); + } finally { + process.argv = argvSnapshot; + } + }); + + it("skips eager warmup for gateway commands that do not need model metadata at startup", async () => { + const loadConfigMock = vi.fn(() => ({ models: {} })); + mockContextModuleDeps(loadConfigMock); + + const argvSnapshot = process.argv; + process.argv = ["node", "openclaw", "gateway", "status", "--json"]; + try { + await import("./context.js"); + expect(loadConfigMock).not.toHaveBeenCalled(); + } finally { + process.argv = argvSnapshot; + } + }); + it("retries config loading after backoff when an initial load fails", async () => { vi.useFakeTimers(); const loadConfigMock = vi diff --git a/src/agents/context.ts b/src/agents/context.ts index 5550f67e3b7..cfeee26cd60 100644 --- a/src/agents/context.ts +++ b/src/agents/context.ts @@ -114,11 +114,13 @@ const SKIP_EAGER_WARMUP_PRIMARY_COMMANDS = new Set([ "config", "directory", "doctor", + "gateway", "health", "hooks", "logs", "plugins", "secrets", + "status", "update", "webhooks", ]); From ca2f0466686d5ff39ef75d1e77d0c88f07ca5383 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:56:44 -0700 Subject: [PATCH 169/943] Status: route JSON through lean command --- src/cli/program/routes.test.ts | 25 +++++++++ src/cli/program/routes.ts | 5 ++ src/commands/status-json.ts | 100 +++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 src/commands/status-json.ts diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index 896dcb6757a..65cba06e299 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -6,6 +6,7 @@ const runConfigUnsetMock = vi.hoisted(() => vi.fn(async () => {})); const modelsListCommandMock = vi.hoisted(() => vi.fn(async () => {})); const modelsStatusCommandMock = vi.hoisted(() => vi.fn(async () => {})); const gatewayStatusCommandMock = vi.hoisted(() => vi.fn(async () => {})); +const statusJsonCommandMock = vi.hoisted(() => vi.fn(async () => {})); vi.mock("../config-cli.js", () => ({ runConfigGet: runConfigGetMock, @@ -21,6 +22,10 @@ vi.mock("../../commands/gateway-status.js", () => ({ gatewayStatusCommand: gatewayStatusCommandMock, })); +vi.mock("../../commands/status-json.js", () => ({ + statusJsonCommand: statusJsonCommandMock, +})); + describe("program routes", () => { beforeEach(() => { vi.clearAllMocks(); @@ -124,6 +129,26 @@ describe("program routes", () => { await expectRunFalse(["status"], ["node", "openclaw", "status", "--timeout"]); }); + it("routes status --json through the lean JSON command", async () => { + const route = expectRoute(["status"]); + await expect( + route?.run([ + "node", + "openclaw", + "status", + "--json", + "--deep", + "--usage", + "--timeout", + "5000", + ]), + ).resolves.toBe(true); + expect(statusJsonCommandMock).toHaveBeenCalledWith( + { deep: true, all: false, usage: true, timeoutMs: 5000 }, + expect.any(Object), + ); + }); + it("returns false for sessions route when --store value is missing", async () => { await expectRunFalse(["sessions"], ["node", "openclaw", "sessions", "--store"]); }); diff --git a/src/cli/program/routes.ts b/src/cli/program/routes.ts index 353c9b8f11d..913f84dd2e4 100644 --- a/src/cli/program/routes.ts +++ b/src/cli/program/routes.ts @@ -47,6 +47,11 @@ const routeStatus: RouteSpec = { if (timeoutMs === null) { return false; } + if (json) { + const { statusJsonCommand } = await import("../../commands/status-json.js"); + await statusJsonCommand({ deep, all, usage, timeoutMs }, defaultRuntime); + return true; + } const { statusCommand } = await import("../../commands/status.js"); await statusCommand({ json, deep, all, usage, timeoutMs, verbose }, defaultRuntime); return true; diff --git a/src/commands/status-json.ts b/src/commands/status-json.ts new file mode 100644 index 00000000000..035f2c71245 --- /dev/null +++ b/src/commands/status-json.ts @@ -0,0 +1,100 @@ +import { callGateway } from "../gateway/call.js"; +import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js"; +import { normalizeUpdateChannel, resolveUpdateChannelDisplay } from "../infra/update-channels.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { runSecurityAudit } from "../security/audit.js"; +import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js"; +import { scanStatus } from "./status.scan.js"; + +let providerUsagePromise: Promise | undefined; + +function loadProviderUsage() { + providerUsagePromise ??= import("../infra/provider-usage.js"); + return providerUsagePromise; +} + +export async function statusJsonCommand( + opts: { + deep?: boolean; + usage?: boolean; + timeoutMs?: number; + all?: boolean; + }, + runtime: RuntimeEnv, +) { + const scan = await scanStatus({ json: true, timeoutMs: opts.timeoutMs, all: opts.all }, runtime); + const securityAudit = await runSecurityAudit({ + config: scan.cfg, + sourceConfig: scan.sourceConfig, + deep: false, + includeFilesystem: true, + includeChannelSecurity: true, + }); + + const usage = opts.usage + ? await loadProviderUsage().then(({ loadProviderUsageSummary }) => + loadProviderUsageSummary({ timeoutMs: opts.timeoutMs }), + ) + : undefined; + const health = opts.deep + ? await callGateway({ + method: "health", + params: { probe: true }, + timeoutMs: opts.timeoutMs, + config: scan.cfg, + }).catch(() => undefined) + : undefined; + const lastHeartbeat = + opts.deep && scan.gatewayReachable + ? await callGateway({ + method: "last-heartbeat", + params: {}, + timeoutMs: opts.timeoutMs, + config: scan.cfg, + }).catch(() => null) + : null; + + const [daemon, nodeDaemon] = await Promise.all([ + getDaemonStatusSummary(), + getNodeDaemonStatusSummary(), + ]); + const channelInfo = resolveUpdateChannelDisplay({ + configChannel: normalizeUpdateChannel(scan.cfg.update?.channel), + installKind: scan.update.installKind, + gitTag: scan.update.git?.tag ?? null, + gitBranch: scan.update.git?.branch ?? null, + }); + + runtime.log( + JSON.stringify( + { + ...scan.summary, + os: scan.osSummary, + update: scan.update, + updateChannel: channelInfo.channel, + updateChannelSource: channelInfo.source, + memory: scan.memory, + memoryPlugin: scan.memoryPlugin, + gateway: { + mode: scan.gatewayMode, + url: scan.gatewayConnection.url, + urlSource: scan.gatewayConnection.urlSource, + misconfigured: scan.remoteUrlMissing, + reachable: scan.gatewayReachable, + connectLatencyMs: scan.gatewayProbe?.connectLatencyMs ?? null, + self: scan.gatewaySelf, + error: scan.gatewayProbe?.error ?? null, + authWarning: scan.gatewayProbeAuthWarning ?? null, + }, + gatewayService: daemon, + nodeService: nodeDaemon, + agents: scan.agentStatus, + securityAudit, + secretDiagnostics: scan.secretDiagnostics, + ...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}), + }, + null, + 2, + ), + ); +} From a33caab280f3e289005e4d37bc6449208a0d3d8d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 20:58:59 -0700 Subject: [PATCH 170/943] refactor(plugins): move auth and model policy to providers --- docs/concepts/model-providers.md | 21 +- docs/tools/plugin.md | 56 +- extensions/anthropic/index.ts | 74 ++- extensions/github-copilot/index.ts | 3 + extensions/google/gemini-cli-provider.test.ts | 67 ++- extensions/google/gemini-cli-provider.ts | 58 +- extensions/google/index.ts | 11 + extensions/google/openclaw.plugin.json | 5 +- extensions/google/provider-models.ts | 63 ++ extensions/minimax/index.ts | 6 + extensions/openai/openai-codex-provider.ts | 63 +- extensions/openai/openai-provider.ts | 11 +- extensions/openai/shared.ts | 8 + extensions/opencode-go/index.ts | 1 + extensions/opencode/index.ts | 10 + extensions/openrouter/index.ts | 1 + extensions/zai/index.ts | 10 + src/agents/live-model-filter.ts | 15 + src/agents/model-compat.test.ts | 136 +---- src/agents/model-forward-compat.ts | 123 ---- src/agents/pi-embedded-runner/model.ts | 50 -- src/auto-reply/thinking.test.ts | 43 +- src/auto-reply/thinking.ts | 60 +- src/commands/models/auth.test.ts | 139 +++-- src/commands/models/auth.ts | 538 ++++++++++-------- src/plugin-sdk/core.ts | 3 + src/plugin-sdk/index.ts | 3 + src/plugins/provider-runtime.test.ts | 49 ++ src/plugins/provider-runtime.ts | 43 ++ src/plugins/types.ts | 63 ++ 30 files changed, 1080 insertions(+), 653 deletions(-) create mode 100644 extensions/google/provider-models.ts delete mode 100644 src/agents/model-forward-compat.ts diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index aa4b90fd41f..eb0f8a1c6a2 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -25,8 +25,10 @@ For model selection rules, see [/concepts/models](/concepts/models). `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `isCacheTtlEligible`, `buildMissingAuthMessage`, - `suppressBuiltInModel`, `augmentModelCatalog`, `prepareRuntimeAuth`, - `resolveUsageAuth`, and `fetchUsageSnapshot`. + `suppressBuiltInModel`, `augmentModelCatalog`, `isBinaryThinking`, + `supportsXHighThinking`, `resolveDefaultThinkingLevel`, + `isModernModelRef`, `prepareRuntimeAuth`, `resolveUsageAuth`, and + `fetchUsageSnapshot`. ## Plugin-owned provider behavior @@ -51,6 +53,11 @@ Typical split: vendor-owned error for direct resolution failures - `augmentModelCatalog`: provider appends synthetic/final catalog rows after discovery and config merging +- `isBinaryThinking`: provider owns binary on/off thinking UX +- `supportsXHighThinking`: provider opts selected models into `xhigh` +- `resolveDefaultThinkingLevel`: provider owns default `/think` policy for a + model family +- `isModernModelRef`: provider owns live/smoke preferred-model matching - `prepareRuntimeAuth`: provider turns a configured credential into a short lived runtime token - `resolveUsageAuth`: provider resolves usage/quota credentials for `/usage` @@ -68,14 +75,16 @@ Current bundled examples: hints, runtime token exchange, and usage endpoint fetching - `openai`: GPT-5.4 forward-compat fallback, direct OpenAI transport normalization, Codex-aware missing-auth hints, Spark suppression, synthetic - OpenAI/Codex catalog rows, and provider-family metadata -- `google-gemini-cli`: Gemini 3.1 forward-compat fallback plus usage-token - parsing and quota endpoint fetching for usage surfaces + OpenAI/Codex catalog rows, thinking/live-model policy, and + provider-family metadata +- `google` and `google-gemini-cli`: Gemini 3.1 forward-compat fallback and + modern-model matching; Gemini CLI OAuth also owns usage-token parsing and + quota endpoint fetching for usage surfaces - `moonshot`: shared transport, plugin-owned thinking payload normalization - `kilocode`: shared transport, plugin-owned request headers, reasoning payload normalization, Gemini transcript hints, and cache-TTL policy - `zai`: GLM-5 forward-compat fallback, `tool_stream` defaults, cache-TTL - policy, and usage auth + quota fetching + policy, binary-thinking/live-model policy, and usage auth + quota fetching - `mistral`, `opencode`, and `opencode-go`: plugin-owned capability metadata - `byteplus`, `cloudflare-ai-gateway`, `huggingface`, `kimi-coding`, `minimax-portal`, `modelstudio`, `nvidia`, `qianfan`, `qwen-portal`, diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index c39401bebfc..62350fb9dd4 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -220,7 +220,7 @@ Provider plugins now have two layers: - manifest metadata: `providerAuthEnvVars` for cheap env-auth lookup before runtime load - config-time hooks: `catalog` / legacy `discovery` -- runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `isCacheTtlEligible`, `buildMissingAuthMessage`, `suppressBuiltInModel`, `augmentModelCatalog`, `prepareRuntimeAuth`, `resolveUsageAuth`, `fetchUsageSnapshot` +- runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `isCacheTtlEligible`, `buildMissingAuthMessage`, `suppressBuiltInModel`, `augmentModelCatalog`, `isBinaryThinking`, `supportsXHighThinking`, `resolveDefaultThinkingLevel`, `isModernModelRef`, `prepareRuntimeAuth`, `resolveUsageAuth`, `fetchUsageSnapshot` OpenClaw still owns the generic agent loop, failover, transcript handling, and tool policy. These hooks are the seam for provider-specific behavior without @@ -263,13 +263,22 @@ For model/provider plugins, OpenClaw uses hooks in this rough order: error hint. 12. `augmentModelCatalog` Provider-owned synthetic/final catalog rows appended after discovery. -13. `prepareRuntimeAuth` +13. `isBinaryThinking` + Provider-owned on/off reasoning toggle for binary-thinking providers. +14. `supportsXHighThinking` + Provider-owned `xhigh` reasoning support for selected models. +15. `resolveDefaultThinkingLevel` + Provider-owned default `/think` level for a specific model family. +16. `isModernModelRef` + Provider-owned modern-model matcher used by live profile filters and smoke + selection. +17. `prepareRuntimeAuth` Exchanges a configured credential into the actual runtime token/key just before inference. -14. `resolveUsageAuth` +18. `resolveUsageAuth` Resolves usage/billing credentials for `/usage` and related status surfaces. -15. `fetchUsageSnapshot` +19. `fetchUsageSnapshot` Fetches and normalizes provider-specific usage/quota snapshots after auth is resolved. @@ -286,6 +295,10 @@ For model/provider plugins, OpenClaw uses hooks in this rough order: - `buildMissingAuthMessage`: replace the generic auth-store error with a provider-specific recovery hint - `suppressBuiltInModel`: hide stale upstream rows and optionally return a provider-owned error for direct resolution failures - `augmentModelCatalog`: append synthetic/final catalog rows after discovery and config merging +- `isBinaryThinking`: expose binary on/off reasoning UX without hardcoding provider ids in `/think` +- `supportsXHighThinking`: opt specific models into the `xhigh` reasoning level +- `resolveDefaultThinkingLevel`: keep provider/model default reasoning policy out of core +- `isModernModelRef`: keep live/smoke model family inclusion rules with the provider - `prepareRuntimeAuth`: exchange a configured credential into the actual short-lived runtime token/key used for requests - `resolveUsageAuth`: resolve provider-owned credentials for usage/billing endpoints without hardcoding token parsing in core - `fetchUsageSnapshot`: own provider-specific usage endpoint fetch/parsing while core keeps summary fan-out and formatting @@ -303,6 +316,10 @@ Rule of thumb: - provider needs a provider-specific missing-auth recovery hint: use `buildMissingAuthMessage` - provider needs to hide stale upstream rows or replace them with a vendor hint: use `suppressBuiltInModel` - provider needs synthetic forward-compat rows in `models list` and pickers: use `augmentModelCatalog` +- provider exposes only binary thinking on/off: use `isBinaryThinking` +- provider wants `xhigh` on only a subset of models: use `supportsXHighThinking` +- provider owns default `/think` policy for a model family: use `resolveDefaultThinkingLevel` +- provider owns live/smoke preferred-model matching: use `isModernModelRef` - provider needs a token exchange or short-lived request credential: use `prepareRuntimeAuth` - provider needs custom usage/quota token parsing or a different usage credential: use `resolveUsageAuth` - provider needs a provider-specific usage endpoint or payload parser: use `fetchUsageSnapshot` @@ -368,14 +385,17 @@ api.registerProvider({ ### Built-in examples - Anthropic uses `resolveDynamicModel`, `capabilities`, `resolveUsageAuth`, - `fetchUsageSnapshot`, and `isCacheTtlEligible` because it owns Claude 4.6 - forward-compat, provider-family hints, usage endpoint integration, and - prompt-cache eligibility. + `fetchUsageSnapshot`, `isCacheTtlEligible`, `resolveDefaultThinkingLevel`, + and `isModernModelRef` because it owns Claude 4.6 forward-compat, + provider-family hints, usage endpoint integration, prompt-cache + eligibility, and Claude default/adaptive thinking policy. - OpenAI uses `resolveDynamicModel`, `normalizeResolvedModel`, and - `capabilities` plus `buildMissingAuthMessage`, `suppressBuiltInModel`, and - `augmentModelCatalog` because it owns GPT-5.4 forward-compat, the direct - OpenAI `openai-completions` -> `openai-responses` normalization, Codex-aware - auth hints, Spark suppression, and synthetic OpenAI list rows. + `capabilities` plus `buildMissingAuthMessage`, `suppressBuiltInModel`, + `augmentModelCatalog`, `supportsXHighThinking`, and `isModernModelRef` + because it owns GPT-5.4 forward-compat, the direct OpenAI + `openai-completions` -> `openai-responses` normalization, Codex-aware auth + hints, Spark suppression, synthetic OpenAI list rows, and GPT-5 thinking / + live-model policy. - OpenRouter uses `catalog` plus `resolveDynamicModel` and `prepareDynamicModel` because the provider is pass-through and may expose new model ids before OpenClaw's static catalog updates. @@ -389,9 +409,10 @@ api.registerProvider({ still runs on core OpenAI transports but owns its transport/base URL normalization, default transport choice, synthetic Codex catalog rows, and ChatGPT usage endpoint integration. -- Gemini CLI OAuth uses `resolveDynamicModel`, `resolveUsageAuth`, and - `fetchUsageSnapshot` because it owns Gemini 3.1 forward-compat fallback plus - the token parsing and quota endpoint wiring needed by `/usage`. +- Google AI Studio and Gemini CLI OAuth use `resolveDynamicModel` and + `isModernModelRef` because they own Gemini 3.1 forward-compat fallback and + modern-model matching; Gemini CLI OAuth also uses `resolveUsageAuth` and + `fetchUsageSnapshot` for token parsing and quota endpoint wiring. - OpenRouter uses `capabilities`, `wrapStreamFn`, and `isCacheTtlEligible` to keep provider-specific request headers, routing metadata, reasoning patches, and prompt-cache policy out of core. @@ -402,9 +423,10 @@ api.registerProvider({ reasoning payload normalization, Gemini transcript hints, and Anthropic cache-TTL gating. - Z.AI uses `resolveDynamicModel`, `prepareExtraParams`, `wrapStreamFn`, - `isCacheTtlEligible`, `resolveUsageAuth`, and `fetchUsageSnapshot` because it - owns GLM-5 fallback, `tool_stream` defaults, and both usage auth + quota - fetching. + `isCacheTtlEligible`, `isBinaryThinking`, `isModernModelRef`, + `resolveUsageAuth`, and `fetchUsageSnapshot` because it owns GLM-5 fallback, + `tool_stream` defaults, binary thinking UX, modern-model matching, and both + usage auth + quota fetching. - Mistral, OpenCode Zen, and OpenCode Go use `capabilities` only to keep transcript/tooling quirks out of core. - Catalog-only bundled providers such as `byteplus`, `cloudflare-ai-gateway`, diff --git a/extensions/anthropic/index.ts b/extensions/anthropic/index.ts index bb17f9d4dc1..5ea7e20b6d9 100644 --- a/extensions/anthropic/index.ts +++ b/extensions/anthropic/index.ts @@ -1,11 +1,14 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi, + type ProviderAuthContext, type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; import { normalizeModelCompat } from "../../src/agents/model-compat.js"; +import { buildTokenProfileId, validateAnthropicSetupToken } from "../../src/commands/auth-token.js"; import { fetchClaudeUsage } from "../../src/infra/provider-usage.fetch.js"; +import type { ProviderAuthResult } from "../../src/plugins/types.js"; const PROVIDER_ID = "anthropic"; const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6"; @@ -14,6 +17,13 @@ const ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] const ANTHROPIC_SONNET_46_MODEL_ID = "claude-sonnet-4-6"; const ANTHROPIC_SONNET_46_DOT_MODEL_ID = "claude-sonnet-4.6"; const ANTHROPIC_SONNET_TEMPLATE_MODEL_IDS = ["claude-sonnet-4-5", "claude-sonnet-4.5"] as const; +const ANTHROPIC_MODERN_MODEL_PREFIXES = [ + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-opus-4-5", + "claude-sonnet-4-5", + "claude-haiku-4-5", +] as const; function cloneFirstTemplateModel(params: { modelId: string; @@ -96,6 +106,51 @@ function resolveAnthropicForwardCompatModel( ); } +function matchesAnthropicModernModel(modelId: string): boolean { + const lower = modelId.trim().toLowerCase(); + return ANTHROPIC_MODERN_MODEL_PREFIXES.some((prefix) => lower.startsWith(prefix)); +} + +async function runAnthropicSetupToken(ctx: ProviderAuthContext): Promise { + await ctx.prompter.note( + ["Run `claude setup-token` in your terminal.", "Then paste the generated token below."].join( + "\n", + ), + "Anthropic setup-token", + ); + + const tokenRaw = await ctx.prompter.text({ + message: "Paste Anthropic setup-token", + validate: (value) => validateAnthropicSetupToken(String(value ?? "")), + }); + const token = String(tokenRaw ?? "").trim(); + const tokenError = validateAnthropicSetupToken(token); + if (tokenError) { + throw new Error(tokenError); + } + + const profileNameRaw = await ctx.prompter.text({ + message: "Token name (blank = default)", + placeholder: "default", + }); + + return { + profiles: [ + { + profileId: buildTokenProfileId({ + provider: PROVIDER_ID, + name: String(profileNameRaw ?? ""), + }), + credential: { + type: "token", + provider: PROVIDER_ID, + token, + }, + }, + ], + }; +} + const anthropicPlugin = { id: PROVIDER_ID, name: "Anthropic Provider", @@ -107,12 +162,29 @@ const anthropicPlugin = { label: "Anthropic", docsPath: "/providers/models", envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], - auth: [], + auth: [ + { + id: "setup-token", + label: "setup-token (claude)", + hint: "Paste a setup-token from `claude setup-token`", + kind: "token", + run: async (ctx: ProviderAuthContext) => await runAnthropicSetupToken(ctx), + }, + ], resolveDynamicModel: (ctx) => resolveAnthropicForwardCompatModel(ctx), capabilities: { providerFamily: "anthropic", dropThinkingBlockModelHints: ["claude"], }, + isModernModelRef: ({ modelId }) => matchesAnthropicModernModel(modelId), + resolveDefaultThinkingLevel: ({ modelId }) => + matchesAnthropicModernModel(modelId) && + (modelId.toLowerCase().startsWith(ANTHROPIC_OPUS_46_MODEL_ID) || + modelId.toLowerCase().startsWith(ANTHROPIC_OPUS_46_DOT_MODEL_ID) || + modelId.toLowerCase().startsWith(ANTHROPIC_SONNET_46_MODEL_ID) || + modelId.toLowerCase().startsWith(ANTHROPIC_SONNET_46_DOT_MODEL_ID)) + ? "adaptive" + : undefined, resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(), fetchUsageSnapshot: async (ctx) => await fetchClaudeUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 038ed70aec9..41c9deed5ec 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -15,6 +15,7 @@ const PROVIDER_ID = "github-copilot"; const COPILOT_ENV_VARS = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]; const CODEX_GPT_53_MODEL_ID = "gpt-5.3-codex"; const CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const; +const COPILOT_XHIGH_MODEL_IDS = ["gpt-5.2", "gpt-5.2-codex"] as const; function resolveFirstGithubToken(params: { agentDir?: string; env: NodeJS.ProcessEnv }): { githubToken: string; @@ -117,6 +118,8 @@ const githubCopilotPlugin = { capabilities: { dropThinkingBlockModelHints: ["claude"], }, + supportsXHighThinking: ({ modelId }) => + COPILOT_XHIGH_MODEL_IDS.includes(modelId.trim().toLowerCase() as never), prepareRuntimeAuth: async (ctx) => { const token = await resolveCopilotApiToken({ githubToken: ctx.apiKey, diff --git a/extensions/google/gemini-cli-provider.test.ts b/extensions/google/gemini-cli-provider.test.ts index 341ecd9e0b9..21e7f505521 100644 --- a/extensions/google/gemini-cli-provider.test.ts +++ b/extensions/google/gemini-cli-provider.test.ts @@ -7,8 +7,16 @@ import { } from "../../src/test-utils/provider-usage-fetch.js"; import googlePlugin from "./index.js"; +function findProvider(providers: ProviderPlugin[], id: string): ProviderPlugin { + const provider = providers.find((candidate) => candidate.id === id); + if (!provider) { + throw new Error(`provider ${id} missing`); + } + return provider; +} + function registerGooglePlugin(): { - provider: ProviderPlugin; + providers: ProviderPlugin[]; webSearchProvider: { id: string; envVars: string[]; @@ -18,13 +26,12 @@ function registerGooglePlugin(): { } { const captured = createCapturedPluginRegistration(); googlePlugin.register(captured.api); - const provider = captured.providers[0]; - if (!provider) { + if (captured.providers.length === 0) { throw new Error("provider registration missing"); } const webSearchProvider = captured.webSearchProviders[0] ?? null; return { - provider, + providers: captured.providers, webSearchProviderRegistered: webSearchProvider !== null, webSearchProvider: webSearchProvider === null @@ -38,10 +45,13 @@ function registerGooglePlugin(): { } describe("google plugin", () => { - it("registers both Gemini CLI auth and Gemini web search", () => { + it("registers Google direct, Gemini CLI auth, and Gemini web search", () => { const result = registerGooglePlugin(); - expect(result.provider.id).toBe("google-gemini-cli"); + expect(result.providers.map((provider) => provider.id)).toEqual([ + "google", + "google-gemini-cli", + ]); expect(result.webSearchProviderRegistered).toBe(true); expect(result.webSearchProvider).toMatchObject({ id: "gemini", @@ -50,8 +60,43 @@ describe("google plugin", () => { }); }); - it("owns gemini 3.1 forward-compat resolution", () => { - const { provider } = registerGooglePlugin(); + it("owns google direct gemini 3.1 forward-compat resolution", () => { + const { providers } = registerGooglePlugin(); + const provider = findProvider(providers, "google"); + const model = provider.resolveDynamicModel?.({ + provider: "google", + modelId: "gemini-3.1-pro-preview", + modelRegistry: { + find: (_provider: string, id: string) => + id === "gemini-3-pro-preview" + ? { + id, + name: id, + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_048_576, + maxTokens: 65_536, + } + : null, + } as never, + }); + + expect(model).toMatchObject({ + id: "gemini-3.1-pro-preview", + provider: "google", + api: "google-generative-ai", + baseUrl: "https://generativelanguage.googleapis.com", + reasoning: true, + }); + }); + + it("owns gemini cli 3.1 forward-compat resolution", () => { + const { providers } = registerGooglePlugin(); + const provider = findProvider(providers, "google-gemini-cli"); const model = provider.resolveDynamicModel?.({ provider: "google-gemini-cli", modelId: "gemini-3.1-pro-preview", @@ -82,7 +127,8 @@ describe("google plugin", () => { }); it("owns usage-token parsing", async () => { - const { provider } = registerGooglePlugin(); + const { providers } = registerGooglePlugin(); + const provider = findProvider(providers, "google-gemini-cli"); await expect( provider.resolveUsageAuth?.({ config: {} as never, @@ -101,7 +147,8 @@ describe("google plugin", () => { }); it("owns usage snapshot fetching", async () => { - const { provider } = registerGooglePlugin(); + const { providers } = registerGooglePlugin(); + const provider = findProvider(providers, "google-gemini-cli"); const mockFetch = createProviderUsageFetch(async (url) => { if (url.includes("cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota")) { return makeResponse(200, { diff --git a/extensions/google/gemini-cli-provider.ts b/extensions/google/gemini-cli-provider.ts index b4bb58f7d80..5a3d784a866 100644 --- a/extensions/google/gemini-cli-provider.ts +++ b/extensions/google/gemini-cli-provider.ts @@ -1,22 +1,16 @@ -import { normalizeModelCompat } from "../../src/agents/model-compat.js"; import { fetchGeminiUsage } from "../../src/infra/provider-usage.fetch.js"; import { buildOauthProviderAuthResult } from "../../src/plugin-sdk/provider-auth-result.js"; import type { OpenClawPluginApi, ProviderAuthContext, ProviderFetchUsageSnapshotContext, - ProviderResolveDynamicModelContext, - ProviderRuntimeModel, } from "../../src/plugins/types.js"; import { loginGeminiCliOAuth } from "./oauth.js"; +import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js"; const PROVIDER_ID = "google-gemini-cli"; const PROVIDER_LABEL = "Gemini CLI OAuth"; const DEFAULT_MODEL = "google-gemini-cli/gemini-3.1-pro-preview"; -const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro"; -const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash"; -const GEMINI_3_1_PRO_TEMPLATE_IDS = ["gemini-3-pro-preview"] as const; -const GEMINI_3_1_FLASH_TEMPLATE_IDS = ["gemini-3-flash-preview"] as const; const ENV_VARS = [ "OPENCLAW_GEMINI_OAUTH_CLIENT_ID", "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", @@ -24,30 +18,6 @@ const ENV_VARS = [ "GEMINI_CLI_OAUTH_CLIENT_SECRET", ]; -function cloneFirstTemplateModel(params: { - modelId: string; - templateIds: readonly string[]; - ctx: ProviderResolveDynamicModelContext; -}): ProviderRuntimeModel | undefined { - const trimmedModelId = params.modelId.trim(); - for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { - const template = params.ctx.modelRegistry.find( - PROVIDER_ID, - templateId, - ) as ProviderRuntimeModel | null; - if (!template) { - continue; - } - return normalizeModelCompat({ - ...template, - id: trimmedModelId, - name: trimmedModelId, - reasoning: true, - } as ProviderRuntimeModel); - } - return undefined; -} - function parseGoogleUsageToken(apiKey: string): string { try { const parsed = JSON.parse(apiKey) as { token?: unknown }; @@ -64,28 +34,6 @@ async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) { return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID); } -function resolveGeminiCliForwardCompatModel( - ctx: ProviderResolveDynamicModelContext, -): ProviderRuntimeModel | undefined { - const trimmed = ctx.modelId.trim(); - const lower = trimmed.toLowerCase(); - - let templateIds: readonly string[]; - if (lower.startsWith(GEMINI_3_1_PRO_PREFIX)) { - templateIds = GEMINI_3_1_PRO_TEMPLATE_IDS; - } else if (lower.startsWith(GEMINI_3_1_FLASH_PREFIX)) { - templateIds = GEMINI_3_1_FLASH_TEMPLATE_IDS; - } else { - return undefined; - } - - return cloneFirstTemplateModel({ - modelId: trimmed, - templateIds, - ctx, - }); -} - export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) { api.registerProvider({ id: PROVIDER_ID, @@ -133,7 +81,9 @@ export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) { }, }, ], - resolveDynamicModel: (ctx) => resolveGeminiCliForwardCompatModel(ctx), + resolveDynamicModel: (ctx) => + resolveGoogle31ForwardCompatModel({ providerId: PROVIDER_ID, ctx }), + isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId), resolveUsageAuth: async (ctx) => { const auth = await ctx.resolveOAuthToken(); if (!auth) { diff --git a/extensions/google/index.ts b/extensions/google/index.ts index 806133b6419..0afa07e2ce0 100644 --- a/extensions/google/index.ts +++ b/extensions/google/index.ts @@ -6,6 +6,7 @@ import { import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; import type { OpenClawPluginApi } from "../../src/plugins/types.js"; import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js"; +import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js"; const googlePlugin = { id: "google", @@ -13,6 +14,16 @@ const googlePlugin = { description: "Bundled Google plugin", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { + api.registerProvider({ + id: "google", + label: "Google AI Studio", + docsPath: "/providers/models", + envVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], + auth: [], + resolveDynamicModel: (ctx) => + resolveGoogle31ForwardCompatModel({ providerId: "google", ctx }), + isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId), + }); registerGoogleGeminiCliProvider(api); api.registerWebSearchProvider( createPluginBackedWebSearchProvider({ diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index 1a6d0dcd196..0d64bb18c14 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "google", - "providers": ["google-gemini-cli"], + "providers": ["google", "google-gemini-cli"], + "providerAuthEnvVars": { + "google": ["GEMINI_API_KEY", "GOOGLE_API_KEY"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/google/provider-models.ts b/extensions/google/provider-models.ts new file mode 100644 index 00000000000..0a086780b1a --- /dev/null +++ b/extensions/google/provider-models.ts @@ -0,0 +1,63 @@ +import { normalizeModelCompat } from "../../src/agents/model-compat.js"; +import type { + ProviderResolveDynamicModelContext, + ProviderRuntimeModel, +} from "../../src/plugins/types.js"; + +const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro"; +const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash"; +const GEMINI_3_1_PRO_TEMPLATE_IDS = ["gemini-3-pro-preview"] as const; +const GEMINI_3_1_FLASH_TEMPLATE_IDS = ["gemini-3-flash-preview"] as const; + +function cloneFirstTemplateModel(params: { + providerId: string; + modelId: string; + templateIds: readonly string[]; + ctx: ProviderResolveDynamicModelContext; +}): ProviderRuntimeModel | undefined { + const trimmedModelId = params.modelId.trim(); + for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { + const template = params.ctx.modelRegistry.find( + params.providerId, + templateId, + ) as ProviderRuntimeModel | null; + if (!template) { + continue; + } + return normalizeModelCompat({ + ...template, + id: trimmedModelId, + name: trimmedModelId, + reasoning: true, + } as ProviderRuntimeModel); + } + return undefined; +} + +export function resolveGoogle31ForwardCompatModel(params: { + providerId: string; + ctx: ProviderResolveDynamicModelContext; +}): ProviderRuntimeModel | undefined { + const trimmed = params.ctx.modelId.trim(); + const lower = trimmed.toLowerCase(); + + let templateIds: readonly string[]; + if (lower.startsWith(GEMINI_3_1_PRO_PREFIX)) { + templateIds = GEMINI_3_1_PRO_TEMPLATE_IDS; + } else if (lower.startsWith(GEMINI_3_1_FLASH_PREFIX)) { + templateIds = GEMINI_3_1_FLASH_TEMPLATE_IDS; + } else { + return undefined; + } + + return cloneFirstTemplateModel({ + providerId: params.providerId, + modelId: trimmed, + templateIds, + ctx: params.ctx, + }); +} + +export function isModernGoogleModel(modelId: string): boolean { + return modelId.trim().toLowerCase().startsWith("gemini-3"); +} diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index e99f5bf15b2..0231fd86236 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -30,6 +30,10 @@ function modelRef(modelId: string): string { return `${PORTAL_PROVIDER_ID}/${modelId}`; } +function isModernMiniMaxModel(modelId: string): boolean { + return modelId.trim().toLowerCase().startsWith("minimax-m2.5"); +} + function buildPortalProviderCatalog(params: { baseUrl: string; apiKey: string }) { return { ...buildMinimaxPortalProvider(), @@ -167,6 +171,7 @@ const minimaxPlugin = { }); return apiKey ? { token: apiKey } : null; }, + isModernModelRef: ({ modelId }) => isModernMiniMaxModel(modelId), fetchUsageSnapshot: async (ctx) => await fetchMinimaxUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), }); @@ -195,6 +200,7 @@ const minimaxPlugin = { run: createOAuthHandler("cn"), }, ], + isModernModelRef: ({ modelId }) => isModernMiniMaxModel(modelId), }); }, }; diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index af5f85d4d21..68058170f19 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -1,4 +1,5 @@ import type { + ProviderAuthContext, ProviderResolveDynamicModelContext, ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; @@ -8,9 +9,16 @@ import { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.js"; import { normalizeModelCompat } from "../../src/agents/model-compat.js"; import { normalizeProviderId } from "../../src/agents/model-selection.js"; import { buildOpenAICodexProvider } from "../../src/agents/models-config.providers.static.js"; +import { loginOpenAICodexOAuth } from "../../src/commands/openai-codex-oauth.js"; import { fetchCodexUsage } from "../../src/infra/provider-usage.fetch.js"; +import { buildOauthProviderAuthResult } from "../../src/plugin-sdk/provider-auth-result.js"; import type { ProviderPlugin } from "../../src/plugins/types.js"; -import { cloneFirstTemplateModel, findCatalogTemplate, isOpenAIApiBaseUrl } from "./shared.js"; +import { + cloneFirstTemplateModel, + findCatalogTemplate, + isOpenAIApiBaseUrl, + matchesExactOrPrefix, +} from "./shared.js"; const PROVIDER_ID = "openai-codex"; const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; @@ -23,6 +31,24 @@ const OPENAI_CODEX_GPT_53_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; const OPENAI_CODEX_GPT_53_SPARK_CONTEXT_TOKENS = 128_000; const OPENAI_CODEX_GPT_53_SPARK_MAX_TOKENS = 128_000; const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const; +const OPENAI_CODEX_DEFAULT_MODEL = `${PROVIDER_ID}/${OPENAI_CODEX_GPT_54_MODEL_ID}`; +const OPENAI_CODEX_XHIGH_MODEL_IDS = [ + OPENAI_CODEX_GPT_54_MODEL_ID, + OPENAI_CODEX_GPT_53_MODEL_ID, + OPENAI_CODEX_GPT_53_SPARK_MODEL_ID, + "gpt-5.2-codex", + "gpt-5.1-codex", +] as const; +const OPENAI_CODEX_MODERN_MODEL_IDS = [ + OPENAI_CODEX_GPT_54_MODEL_ID, + "gpt-5.2", + "gpt-5.2-codex", + OPENAI_CODEX_GPT_53_MODEL_ID, + OPENAI_CODEX_GPT_53_SPARK_MODEL_ID, + "gpt-5.1-codex", + "gpt-5.1-codex-mini", + "gpt-5.1-codex-max", +] as const; function isOpenAICodexBaseUrl(baseUrl?: string): boolean { const trimmed = baseUrl?.trim(); @@ -106,12 +132,42 @@ function resolveCodexForwardCompatModel( ); } +async function runOpenAICodexOAuth(ctx: ProviderAuthContext) { + const creds = await loginOpenAICodexOAuth({ + prompter: ctx.prompter, + runtime: ctx.runtime, + isRemote: ctx.isRemote, + openUrl: ctx.openUrl, + localBrowserMessage: "Complete sign-in in browser…", + }); + if (!creds) { + throw new Error("OpenAI Codex OAuth did not return credentials."); + } + + return buildOauthProviderAuthResult({ + providerId: PROVIDER_ID, + defaultModel: OPENAI_CODEX_DEFAULT_MODEL, + access: creds.access, + refresh: creds.refresh, + expires: creds.expires, + email: typeof creds.email === "string" ? creds.email : undefined, + }); +} + export function buildOpenAICodexProviderPlugin(): ProviderPlugin { return { id: PROVIDER_ID, label: "OpenAI Codex", docsPath: "/providers/models", - auth: [], + auth: [ + { + id: "oauth", + label: "ChatGPT OAuth", + hint: "Browser sign-in", + kind: "oauth", + run: async (ctx) => await runOpenAICodexOAuth(ctx), + }, + ], catalog: { order: "profile", run: async (ctx) => { @@ -130,6 +186,9 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin { capabilities: { providerFamily: "openai", }, + supportsXHighThinking: ({ modelId }) => + matchesExactOrPrefix(modelId, OPENAI_CODEX_XHIGH_MODEL_IDS), + isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_CODEX_MODERN_MODEL_IDS), prepareExtraParams: (ctx) => { const transport = ctx.extraParams?.transport; if (transport === "auto" || transport === "sse" || transport === "websocket") { diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index 9ce61e2a2b8..be406f26bbb 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -5,7 +5,12 @@ import { import { normalizeModelCompat } from "../../src/agents/model-compat.js"; import { normalizeProviderId } from "../../src/agents/model-selection.js"; import type { ProviderPlugin } from "../../src/plugins/types.js"; -import { cloneFirstTemplateModel, findCatalogTemplate, isOpenAIApiBaseUrl } from "./shared.js"; +import { + cloneFirstTemplateModel, + findCatalogTemplate, + isOpenAIApiBaseUrl, + matchesExactOrPrefix, +} from "./shared.js"; const PROVIDER_ID = "openai"; const OPENAI_GPT_54_MODEL_ID = "gpt-5.4"; @@ -14,6 +19,8 @@ const OPENAI_GPT_54_CONTEXT_TOKENS = 1_050_000; const OPENAI_GPT_54_MAX_TOKENS = 128_000; const OPENAI_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.2"] as const; const OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS = ["gpt-5.2-pro", "gpt-5.2"] as const; +const OPENAI_XHIGH_MODEL_IDS = ["gpt-5.4", "gpt-5.4-pro", "gpt-5.2"] as const; +const OPENAI_MODERN_MODEL_IDS = ["gpt-5.4", "gpt-5.4-pro", "gpt-5.2", "gpt-5.0"] as const; const OPENAI_DIRECT_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; const SUPPRESSED_SPARK_PROVIDERS = new Set(["openai", "azure-openai-responses"]); @@ -93,6 +100,8 @@ export function buildOpenAIProvider(): ProviderPlugin { capabilities: { providerFamily: "openai", }, + supportsXHighThinking: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_XHIGH_MODEL_IDS), + isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_MODERN_MODEL_IDS), buildMissingAuthMessage: (ctx) => { if (ctx.provider !== PROVIDER_ID || ctx.listProfileIds("openai-codex").length === 0) { return undefined; diff --git a/extensions/openai/shared.ts b/extensions/openai/shared.ts index c8654be2f9b..4e4c8c2d850 100644 --- a/extensions/openai/shared.ts +++ b/extensions/openai/shared.ts @@ -6,6 +6,14 @@ import type { export const OPENAI_API_BASE_URL = "https://api.openai.com/v1"; +export function matchesExactOrPrefix(id: string, values: readonly string[]): boolean { + const normalizedId = id.trim().toLowerCase(); + return values.some((value) => { + const normalizedValue = value.trim().toLowerCase(); + return normalizedId === normalizedValue || normalizedId.startsWith(normalizedValue); + }); +} + export function isOpenAIApiBaseUrl(baseUrl?: string): boolean { const trimmed = baseUrl?.trim(); if (!trimmed) { diff --git a/extensions/opencode-go/index.ts b/extensions/opencode-go/index.ts index 3740c0190c4..87e52eab53e 100644 --- a/extensions/opencode-go/index.ts +++ b/extensions/opencode-go/index.ts @@ -19,6 +19,7 @@ const opencodeGoPlugin = { geminiThoughtSignatureSanitization: true, geminiThoughtSignatureModelHints: ["gemini"], }, + isModernModelRef: () => true, }); }, }; diff --git a/extensions/opencode/index.ts b/extensions/opencode/index.ts index 81175fc5613..c800961ab36 100644 --- a/extensions/opencode/index.ts +++ b/extensions/opencode/index.ts @@ -1,6 +1,15 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; const PROVIDER_ID = "opencode"; +const MINIMAX_PREFIX = "minimax-m2.5"; + +function isModernOpencodeModel(modelId: string): boolean { + const lower = modelId.trim().toLowerCase(); + if (lower.endsWith("-free") || lower === "alpha-glm-4.7") { + return false; + } + return !lower.startsWith(MINIMAX_PREFIX); +} const opencodePlugin = { id: PROVIDER_ID, @@ -19,6 +28,7 @@ const opencodePlugin = { geminiThoughtSignatureSanitization: true, geminiThoughtSignatureModelHints: ["gemini"], }, + isModernModelRef: ({ modelId }) => isModernOpencodeModel(modelId), }); }, }; diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index faa7b338cf1..92521cb3984 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -110,6 +110,7 @@ const openRouterPlugin = { geminiThoughtSignatureSanitization: true, geminiThoughtSignatureModelHints: ["gemini"], }, + isModernModelRef: () => true, wrapStreamFn: (ctx) => { let streamFn = ctx.streamFn; const providerRouting = diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts index d9b81b87dda..f4fd60ad5c3 100644 --- a/extensions/zai/index.ts +++ b/extensions/zai/index.ts @@ -98,6 +98,16 @@ const zaiPlugin = { }, wrapStreamFn: (ctx) => createZaiToolStreamWrapper(ctx.streamFn, ctx.extraParams?.tool_stream !== false), + isBinaryThinking: () => true, + isModernModelRef: ({ modelId }) => { + const lower = modelId.trim().toLowerCase(); + return ( + lower.startsWith("glm-5") || + lower.startsWith("glm-4.7") || + lower.startsWith("glm-4.7-flash") || + lower.startsWith("glm-4.7-flashx") + ); + }, resolveUsageAuth: async (ctx) => { const apiKey = ctx.resolveApiKeyFromConfigAndStore({ providerIds: [PROVIDER_ID, "z-ai"], diff --git a/src/agents/live-model-filter.ts b/src/agents/live-model-filter.ts index 059e12d9711..e047d70dbde 100644 --- a/src/agents/live-model-filter.ts +++ b/src/agents/live-model-filter.ts @@ -1,3 +1,5 @@ +import { resolveProviderModernModelRef } from "../plugins/provider-runtime.js"; + export type ModelRef = { provider?: string | null; id?: string | null; @@ -41,6 +43,19 @@ export function isModernModelRef(ref: ModelRef): boolean { return false; } + const pluginDecision = resolveProviderModernModelRef({ + provider, + context: { + provider, + modelId: id, + }, + }); + if (typeof pluginDecision === "boolean") { + return pluginDecision; + } + + // Compatibility fallback for core-owned providers and tests that disable + // bundled provider runtime hooks. if (provider === "anthropic") { return matchesPrefix(id, ANTHROPIC_PREFIXES); } diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index 9bb1bf76eff..c473aadf8e6 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -1,9 +1,16 @@ import type { Api, Model } from "@mariozechner/pi-ai"; -import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const providerRuntimeMocks = vi.hoisted(() => ({ + resolveProviderModernModelRef: vi.fn(), +})); + +vi.mock("../plugins/provider-runtime.js", () => ({ + resolveProviderModernModelRef: providerRuntimeMocks.resolveProviderModernModelRef, +})); + import { isModernModelRef } from "./live-model-filter.js"; import { normalizeModelCompat } from "./model-compat.js"; -import { resolveForwardCompatModel } from "./model-forward-compat.js"; const baseModel = (): Model => ({ @@ -32,43 +39,6 @@ function supportsStrictMode(model: Model): boolean | undefined { return (model.compat as { supportsStrictMode?: boolean } | undefined)?.supportsStrictMode; } -function createTemplateModel(provider: string, id: string): Model { - return { - id, - name: id, - provider, - api: "anthropic-messages", - input: ["text"], - reasoning: true, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200_000, - maxTokens: 8_192, - } as Model; -} - -function createOpenAITemplateModel(id: string): Model { - return { - id, - name: id, - provider: "openai", - api: "openai-responses", - baseUrl: "https://api.openai.com/v1", - input: ["text", "image"], - reasoning: true, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 400_000, - maxTokens: 32_768, - } as Model; -} - -function createRegistry(models: Record>): ModelRegistry { - return { - find(provider: string, modelId: string) { - return models[`${provider}/${modelId}`] ?? null; - }, - } as ModelRegistry; -} - function expectSupportsDeveloperRoleForcedOff(overrides?: Partial>): void { const model = { ...baseModel(), ...overrides }; delete (model as { compat?: unknown }).compat; @@ -90,14 +60,10 @@ function expectSupportsStrictModeForcedOff(overrides?: Partial>): voi expect(supportsStrictMode(normalized)).toBe(false); } -function expectResolvedForwardCompat( - model: Model | undefined, - expected: { provider: string; id: string }, -): void { - expect(model?.id).toBe(expected.id); - expect(model?.name).toBe(expected.id); - expect(model?.provider).toBe(expected.provider); -} +beforeEach(() => { + providerRuntimeMocks.resolveProviderModernModelRef.mockReset(); + providerRuntimeMocks.resolveProviderModernModelRef.mockReturnValue(undefined); +}); describe("normalizeModelCompat — Anthropic baseUrl", () => { const anthropicBase = (): Model => @@ -373,6 +339,12 @@ describe("normalizeModelCompat", () => { }); describe("isModernModelRef", () => { + it("uses provider runtime hooks before fallback heuristics", () => { + providerRuntimeMocks.resolveProviderModernModelRef.mockReturnValue(false); + + expect(isModernModelRef({ provider: "openrouter", id: "claude-opus-4-6" })).toBe(false); + }); + it("includes OpenAI gpt-5.4 variants in modern selection", () => { expect(isModernModelRef({ provider: "openai", id: "gpt-5.4" })).toBe(true); expect(isModernModelRef({ provider: "openai", id: "gpt-5.4-pro" })).toBe(true); @@ -395,71 +367,3 @@ describe("isModernModelRef", () => { expect(isModernModelRef({ provider: "opencode-go", id: "minimax-m2.5" })).toBe(true); }); }); - -describe("resolveForwardCompatModel", () => { - it("resolves openai gpt-5.4 via gpt-5.2 template", () => { - const registry = createRegistry({ - "openai/gpt-5.2": createOpenAITemplateModel("gpt-5.2"), - }); - const model = resolveForwardCompatModel("openai", "gpt-5.4", registry); - expectResolvedForwardCompat(model, { provider: "openai", id: "gpt-5.4" }); - expect(model?.api).toBe("openai-responses"); - expect(model?.baseUrl).toBe("https://api.openai.com/v1"); - expect(model?.contextWindow).toBe(1_050_000); - expect(model?.maxTokens).toBe(128_000); - }); - - it("resolves openai gpt-5.4 without templates using normalized fallback defaults", () => { - const registry = createRegistry({}); - - const model = resolveForwardCompatModel("openai", "gpt-5.4", registry); - - expectResolvedForwardCompat(model, { provider: "openai", id: "gpt-5.4" }); - expect(model?.api).toBe("openai-responses"); - expect(model?.baseUrl).toBe("https://api.openai.com/v1"); - expect(model?.input).toEqual(["text", "image"]); - expect(model?.reasoning).toBe(true); - expect(model?.contextWindow).toBe(1_050_000); - expect(model?.maxTokens).toBe(128_000); - expect(model?.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }); - }); - - it("resolves openai gpt-5.4-pro via template fallback", () => { - const registry = createRegistry({ - "openai/gpt-5.2": createOpenAITemplateModel("gpt-5.2"), - }); - const model = resolveForwardCompatModel("openai", "gpt-5.4-pro", registry); - expectResolvedForwardCompat(model, { provider: "openai", id: "gpt-5.4-pro" }); - expect(model?.api).toBe("openai-responses"); - expect(model?.baseUrl).toBe("https://api.openai.com/v1"); - expect(model?.contextWindow).toBe(1_050_000); - expect(model?.maxTokens).toBe(128_000); - }); - - it("resolves anthropic opus 4.6 via 4.5 template", () => { - const registry = createRegistry({ - "anthropic/claude-opus-4-5": createTemplateModel("anthropic", "claude-opus-4-5"), - }); - const model = resolveForwardCompatModel("anthropic", "claude-opus-4-6", registry); - expectResolvedForwardCompat(model, { provider: "anthropic", id: "claude-opus-4-6" }); - }); - - it("resolves anthropic sonnet 4.6 dot variant with suffix", () => { - const registry = createRegistry({ - "anthropic/claude-sonnet-4.5-20260219": createTemplateModel( - "anthropic", - "claude-sonnet-4.5-20260219", - ), - }); - const model = resolveForwardCompatModel("anthropic", "claude-sonnet-4.6-20260219", registry); - expectResolvedForwardCompat(model, { provider: "anthropic", id: "claude-sonnet-4.6-20260219" }); - }); - - it("does not resolve anthropic 4.6 fallback for other providers", () => { - const registry = createRegistry({ - "anthropic/claude-opus-4-5": createTemplateModel("anthropic", "claude-opus-4-5"), - }); - const model = resolveForwardCompatModel("openai", "claude-opus-4-6", registry); - expect(model).toBeUndefined(); - }); -}); diff --git a/src/agents/model-forward-compat.ts b/src/agents/model-forward-compat.ts deleted file mode 100644 index 5319d30423e..00000000000 --- a/src/agents/model-forward-compat.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { Api, Model } from "@mariozechner/pi-ai"; -import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; -import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js"; -import { normalizeModelCompat } from "./model-compat.js"; -import { normalizeProviderId } from "./model-selection.js"; - -const ZAI_GLM5_MODEL_ID = "glm-5"; -const ZAI_GLM5_TEMPLATE_MODEL_IDS = ["glm-4.7"] as const; - -// gemini-3.1-pro-preview / gemini-3.1-flash-preview are not present in some pi-ai -// Google catalogs yet. Clone the nearest gemini-3 template so users don't get -// "Unknown model" errors when Google ships new minor-version models before pi-ai -// updates its built-in registry. -const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro"; -const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash"; -const GEMINI_3_1_PRO_TEMPLATE_IDS = ["gemini-3-pro-preview"] as const; -const GEMINI_3_1_FLASH_TEMPLATE_IDS = ["gemini-3-flash-preview"] as const; - -function cloneFirstTemplateModel(params: { - normalizedProvider: string; - trimmedModelId: string; - templateIds: string[]; - modelRegistry: ModelRegistry; - patch?: Partial>; -}): Model | undefined { - const { normalizedProvider, trimmedModelId, templateIds, modelRegistry } = params; - for (const templateId of [...new Set(templateIds)].filter(Boolean)) { - const template = modelRegistry.find(normalizedProvider, templateId) as Model | null; - if (!template) { - continue; - } - return normalizeModelCompat({ - ...template, - id: trimmedModelId, - name: trimmedModelId, - ...params.patch, - } as Model); - } - return undefined; -} - -function resolveGoogle31ForwardCompatModel( - provider: string, - modelId: string, - modelRegistry: ModelRegistry, -): Model | undefined { - const normalizedProvider = normalizeProviderId(provider); - if (normalizedProvider !== "google" && normalizedProvider !== "google-gemini-cli") { - return undefined; - } - const trimmed = modelId.trim(); - const lower = trimmed.toLowerCase(); - - let templateIds: readonly string[]; - if (lower.startsWith(GEMINI_3_1_PRO_PREFIX)) { - templateIds = GEMINI_3_1_PRO_TEMPLATE_IDS; - } else if (lower.startsWith(GEMINI_3_1_FLASH_PREFIX)) { - templateIds = GEMINI_3_1_FLASH_TEMPLATE_IDS; - } else { - return undefined; - } - - return cloneFirstTemplateModel({ - normalizedProvider, - trimmedModelId: trimmed, - templateIds: [...templateIds], - modelRegistry, - patch: { reasoning: true }, - }); -} - -// Z.ai's GLM-5 may not be present in pi-ai's built-in model catalog yet. -// When a user configures zai/glm-5 without a models.json entry, clone glm-4.7 as a forward-compat fallback. -function resolveZaiGlm5ForwardCompatModel( - provider: string, - modelId: string, - modelRegistry: ModelRegistry, -): Model | undefined { - if (normalizeProviderId(provider) !== "zai") { - return undefined; - } - const trimmed = modelId.trim(); - const lower = trimmed.toLowerCase(); - if (lower !== ZAI_GLM5_MODEL_ID && !lower.startsWith(`${ZAI_GLM5_MODEL_ID}-`)) { - return undefined; - } - - for (const templateId of ZAI_GLM5_TEMPLATE_MODEL_IDS) { - const template = modelRegistry.find("zai", templateId) as Model | null; - if (!template) { - continue; - } - return normalizeModelCompat({ - ...template, - id: trimmed, - name: trimmed, - reasoning: true, - } as Model); - } - - return normalizeModelCompat({ - id: trimmed, - name: trimmed, - api: "openai-completions", - provider: "zai", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: DEFAULT_CONTEXT_TOKENS, - maxTokens: DEFAULT_CONTEXT_TOKENS, - } as Model); -} - -export function resolveForwardCompatModel( - provider: string, - modelId: string, - modelRegistry: ModelRegistry, -): Model | undefined { - return ( - resolveZaiGlm5ForwardCompatModel(provider, modelId, modelRegistry) ?? - resolveGoogle31ForwardCompatModel(provider, modelId, modelRegistry) - ); -} diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index ed6356a361f..5bf97a683d0 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -13,7 +13,6 @@ import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; import { buildModelAliasLines } from "../model-alias-lines.js"; import { isSecretRefHeaderValueMarker } from "../model-auth-markers.js"; import { normalizeModelCompat } from "../model-compat.js"; -import { resolveForwardCompatModel } from "../model-forward-compat.js"; import { findNormalizedProviderValue, normalizeProviderId } from "../model-selection.js"; import { buildSuppressedBuiltInModelError, @@ -34,8 +33,6 @@ type InlineProviderConfig = { headers?: unknown; }; -const PLUGIN_FIRST_DYNAMIC_PROVIDERS = new Set(["google-gemini-cli", "zai"]); - function sanitizeModelHeaders( headers: unknown, opts?: { stripSecretRefMarkers?: boolean }, @@ -232,53 +229,6 @@ function resolveExplicitModelWithRegistry(params: { }; } - if (PLUGIN_FIRST_DYNAMIC_PROVIDERS.has(normalizeProviderId(provider))) { - // Give migrated provider plugins first shot at ids that still keep a core - // forward-compat fallback for disabled-plugin/test compatibility. - const pluginDynamicModel = runProviderDynamicModel({ - provider, - config: cfg, - context: { - config: cfg, - agentDir, - provider, - modelId, - modelRegistry, - providerConfig, - }, - }); - if (pluginDynamicModel) { - return { - kind: "resolved", - model: normalizeResolvedModel({ - provider, - cfg, - agentDir, - model: pluginDynamicModel, - }), - }; - } - } - - // Forward-compat fallbacks must be checked BEFORE the generic providerCfg fallback. - // Otherwise, configured providers can default to a generic API and break specific transports. - const forwardCompat = resolveForwardCompatModel(provider, modelId, modelRegistry); - if (forwardCompat) { - return { - kind: "resolved", - model: normalizeResolvedModel({ - provider, - cfg, - agentDir, - model: applyConfiguredProviderOverrides({ - discoveredModel: forwardCompat, - providerConfig, - modelId, - }), - }), - }; - } - return undefined; } diff --git a/src/auto-reply/thinking.test.ts b/src/auto-reply/thinking.test.ts index d4814a263e9..48113b3ce72 100644 --- a/src/auto-reply/thinking.test.ts +++ b/src/auto-reply/thinking.test.ts @@ -1,4 +1,16 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const providerRuntimeMocks = vi.hoisted(() => ({ + resolveProviderBinaryThinking: vi.fn(), + resolveProviderDefaultThinkingLevel: vi.fn(), + resolveProviderXHighThinking: vi.fn(), +})); + +vi.mock("../plugins/provider-runtime.js", () => ({ + resolveProviderBinaryThinking: providerRuntimeMocks.resolveProviderBinaryThinking, + resolveProviderDefaultThinkingLevel: providerRuntimeMocks.resolveProviderDefaultThinkingLevel, + resolveProviderXHighThinking: providerRuntimeMocks.resolveProviderXHighThinking, +})); import { listThinkingLevelLabels, listThinkingLevels, @@ -7,6 +19,15 @@ import { resolveThinkingDefaultForModel, } from "./thinking.js"; +beforeEach(() => { + providerRuntimeMocks.resolveProviderBinaryThinking.mockReset(); + providerRuntimeMocks.resolveProviderBinaryThinking.mockReturnValue(undefined); + providerRuntimeMocks.resolveProviderDefaultThinkingLevel.mockReset(); + providerRuntimeMocks.resolveProviderDefaultThinkingLevel.mockReturnValue(undefined); + providerRuntimeMocks.resolveProviderXHighThinking.mockReset(); + providerRuntimeMocks.resolveProviderXHighThinking.mockReturnValue(undefined); +}); + describe("normalizeThinkLevel", () => { it("accepts mid as medium", () => { expect(normalizeThinkLevel("mid")).toBe("medium"); @@ -43,6 +64,12 @@ describe("normalizeThinkLevel", () => { }); describe("listThinkingLevels", () => { + it("uses provider runtime hooks for xhigh support", () => { + providerRuntimeMocks.resolveProviderXHighThinking.mockReturnValue(true); + + expect(listThinkingLevels("demo", "demo-model")).toContain("xhigh"); + }); + it("includes xhigh for codex models", () => { expect(listThinkingLevels(undefined, "gpt-5.2-codex")).toContain("xhigh"); expect(listThinkingLevels(undefined, "gpt-5.3-codex")).toContain("xhigh"); @@ -75,6 +102,12 @@ describe("listThinkingLevels", () => { }); describe("listThinkingLevelLabels", () => { + it("uses provider runtime hooks for binary thinking providers", () => { + providerRuntimeMocks.resolveProviderBinaryThinking.mockReturnValue(true); + + expect(listThinkingLevelLabels("demo", "demo-model")).toEqual(["off", "on"]); + }); + it("returns on/off for ZAI", () => { expect(listThinkingLevelLabels("zai", "glm-4.7")).toEqual(["off", "on"]); }); @@ -86,6 +119,14 @@ describe("listThinkingLevelLabels", () => { }); describe("resolveThinkingDefaultForModel", () => { + it("uses provider runtime hooks for default thinking levels", () => { + providerRuntimeMocks.resolveProviderDefaultThinkingLevel.mockReturnValue("adaptive"); + + expect(resolveThinkingDefaultForModel({ provider: "demo", model: "demo-model" })).toBe( + "adaptive", + ); + }); + it("defaults Claude 4.6 models to adaptive", () => { expect( resolveThinkingDefaultForModel({ provider: "anthropic", model: "claude-opus-4-6" }), diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index 639db68eafb..9c03086ab91 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -1,3 +1,9 @@ +import { + resolveProviderBinaryThinking, + resolveProviderDefaultThinkingLevel, + resolveProviderXHighThinking, +} from "../plugins/provider-runtime.js"; + export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive"; export type VerboseLevel = "off" | "on" | "full"; export type NoticeLevel = "off" | "on" | "full"; @@ -27,8 +33,24 @@ function normalizeProviderId(provider?: string | null): string { return normalized; } -export function isBinaryThinkingProvider(provider?: string | null): boolean { - return normalizeProviderId(provider) === "zai"; +export function isBinaryThinkingProvider(provider?: string | null, model?: string | null): boolean { + const normalizedProvider = normalizeProviderId(provider); + if (!normalizedProvider) { + return false; + } + + const pluginDecision = resolveProviderBinaryThinking({ + provider: normalizedProvider, + context: { + provider: normalizedProvider, + modelId: model?.trim() ?? "", + }, + }); + if (typeof pluginDecision === "boolean") { + return pluginDecision; + } + + return normalizedProvider === "zai"; } export const XHIGH_MODEL_REFS = [ @@ -95,7 +117,19 @@ export function supportsXHighThinking(provider?: string | null, model?: string | if (!modelKey) { return false; } - const providerKey = provider?.trim().toLowerCase(); + const providerKey = normalizeProviderId(provider); + if (providerKey) { + const pluginDecision = resolveProviderXHighThinking({ + provider: providerKey, + context: { + provider: providerKey, + modelId: modelKey, + }, + }); + if (typeof pluginDecision === "boolean") { + return pluginDecision; + } + } if (providerKey) { return XHIGH_MODEL_SET.has(`${providerKey}/${modelKey}`); } @@ -112,7 +146,7 @@ export function listThinkingLevels(provider?: string | null, model?: string | nu } export function listThinkingLevelLabels(provider?: string | null, model?: string | null): string[] { - if (isBinaryThinkingProvider(provider)) { + if (isBinaryThinkingProvider(provider, model)) { return ["off", "on"]; } return listThinkingLevels(provider, model); @@ -147,6 +181,21 @@ export function resolveThinkingDefaultForModel(params: { }): ThinkLevel { const normalizedProvider = normalizeProviderId(params.provider); const modelLower = params.model.trim().toLowerCase(); + const candidate = params.catalog?.find( + (entry) => entry.provider === params.provider && entry.id === params.model, + ); + const pluginDecision = resolveProviderDefaultThinkingLevel({ + provider: normalizedProvider, + context: { + provider: normalizedProvider, + modelId: params.model, + reasoning: candidate?.reasoning, + }, + }); + if (pluginDecision) { + return pluginDecision; + } + const isAnthropicFamilyModel = normalizedProvider === "anthropic" || normalizedProvider === "amazon-bedrock" || @@ -155,9 +204,6 @@ export function resolveThinkingDefaultForModel(params: { if (isAnthropicFamilyModel && CLAUDE_46_MODEL_RE.test(modelLower)) { return "adaptive"; } - const candidate = params.catalog?.find( - (entry) => entry.provider === params.provider && entry.id === params.model, - ); if (candidate?.reasoning) { return "low"; } diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index bf8195b5284..6bb052ba3d6 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +import type { ProviderPlugin } from "../../plugins/types.js"; import type { RuntimeEnv } from "../../runtime.js"; const mocks = vi.hoisted(() => ({ @@ -15,8 +16,6 @@ const mocks = vi.hoisted(() => ({ upsertAuthProfile: vi.fn(), resolvePluginProviders: vi.fn(), createClackPrompter: vi.fn(), - loginOpenAICodexOAuth: vi.fn(), - writeOAuthCredentials: vi.fn(), loadValidConfigOrThrow: vi.fn(), updateConfig: vi.fn(), logConfigUpdated: vi.fn(), @@ -59,18 +58,6 @@ vi.mock("../../wizard/clack-prompter.js", () => ({ createClackPrompter: mocks.createClackPrompter, })); -vi.mock("../openai-codex-oauth.js", () => ({ - loginOpenAICodexOAuth: mocks.loginOpenAICodexOAuth, -})); - -vi.mock("../onboard-auth.js", async (importActual) => { - const actual = await importActual(); - return { - ...actual, - writeOAuthCredentials: mocks.writeOAuthCredentials, - }; -}); - vi.mock("./shared.js", async (importActual) => { const actual = await importActual(); return { @@ -88,7 +75,8 @@ vi.mock("../onboard-helpers.js", () => ({ openUrl: mocks.openUrl, })); -const { modelsAuthLoginCommand, modelsAuthPasteTokenCommand } = await import("./auth.js"); +const { modelsAuthLoginCommand, modelsAuthPasteTokenCommand, modelsAuthSetupTokenCommand } = + await import("./auth.js"); function createRuntime(): RuntimeEnv { return { @@ -116,10 +104,30 @@ function withInteractiveStdin() { }; } +function createProvider(params: { + id: string; + label?: string; + run: NonNullable[number]["run"]; +}): ProviderPlugin { + return { + id: params.id, + label: params.label ?? params.id, + auth: [ + { + id: "oauth", + label: "OAuth", + kind: "oauth", + run: params.run, + }, + ], + }; +} + describe("modelsAuthLoginCommand", () => { let restoreStdin: (() => void) | null = null; let currentConfig: OpenClawConfig; let lastUpdatedConfig: OpenClawConfig | null; + let runProviderAuth: ReturnType; beforeEach(() => { vi.clearAllMocks(); @@ -151,16 +159,29 @@ describe("modelsAuthLoginCommand", () => { note: vi.fn(async () => {}), select: vi.fn(), }); - mocks.loginOpenAICodexOAuth.mockResolvedValue({ - type: "oauth", - provider: "openai-codex", - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 60_000, - email: "user@example.com", + runProviderAuth = vi.fn().mockResolvedValue({ + profiles: [ + { + profileId: "openai-codex:user@example.com", + credential: { + type: "oauth", + provider: "openai-codex", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + email: "user@example.com", + }, + }, + ], + defaultModel: "openai-codex/gpt-5.4", }); - mocks.writeOAuthCredentials.mockResolvedValue("openai-codex:user@example.com"); - mocks.resolvePluginProviders.mockReturnValue([]); + mocks.resolvePluginProviders.mockReturnValue([ + createProvider({ + id: "openai-codex", + label: "OpenAI Codex", + run: runProviderAuth as ProviderPlugin["auth"][number]["run"], + }), + ]); mocks.loadAuthProfileStoreForRuntime.mockReturnValue({ profiles: {}, usageStats: {} }); mocks.listProfilesForProvider.mockReturnValue([]); mocks.clearAuthProfileCooldown.mockResolvedValue(undefined); @@ -171,19 +192,20 @@ describe("modelsAuthLoginCommand", () => { restoreStdin = null; }); - it("supports built-in openai-codex login without provider plugins", async () => { + it("runs plugin-owned openai-codex login", async () => { const runtime = createRuntime(); await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime); - expect(mocks.loginOpenAICodexOAuth).toHaveBeenCalledOnce(); - expect(mocks.writeOAuthCredentials).toHaveBeenCalledWith( - "openai-codex", - expect.any(Object), - "/tmp/openclaw/agents/main", - { syncSiblingAgents: true }, - ); - expect(mocks.resolvePluginProviders).not.toHaveBeenCalled(); + expect(runProviderAuth).toHaveBeenCalledOnce(); + expect(mocks.upsertAuthProfile).toHaveBeenCalledWith({ + profileId: "openai-codex:user@example.com", + credential: expect.objectContaining({ + type: "oauth", + provider: "openai-codex", + }), + agentDir: "/tmp/openclaw/agents/main", + }); expect(lastUpdatedConfig?.auth?.profiles?.["openai-codex:user@example.com"]).toMatchObject({ provider: "openai-codex", mode: "oauth", @@ -236,7 +258,7 @@ describe("modelsAuthLoginCommand", () => { }); // Verify clearing happens before login attempt const clearOrder = mocks.clearAuthProfileCooldown.mock.invocationCallOrder[0]; - const loginOrder = mocks.loginOpenAICodexOAuth.mock.invocationCallOrder[0]; + const loginOrder = runProviderAuth.mock.invocationCallOrder[0]; expect(clearOrder).toBeLessThan(loginOrder); }); @@ -248,7 +270,7 @@ describe("modelsAuthLoginCommand", () => { await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime); - expect(mocks.loginOpenAICodexOAuth).toHaveBeenCalledOnce(); + expect(runProviderAuth).toHaveBeenCalledOnce(); }); it("loads lockout state from the agent-scoped store", async () => { @@ -261,11 +283,11 @@ describe("modelsAuthLoginCommand", () => { expect(mocks.loadAuthProfileStoreForRuntime).toHaveBeenCalledWith("/tmp/openclaw/agents/main"); }); - it("keeps existing plugin error behavior for non built-in providers", async () => { + it("reports loaded plugin providers when requested provider is unavailable", async () => { const runtime = createRuntime(); await expect(modelsAuthLoginCommand({ provider: "anthropic" }, runtime)).rejects.toThrow( - "No provider plugins found.", + 'Unknown provider "anthropic". Loaded providers: openai-codex. Verify plugins via `openclaw plugins list --json`.', ); }); @@ -292,4 +314,47 @@ describe("modelsAuthLoginCommand", () => { exitSpy.mockRestore(); } }); + + it("runs token auth for any token-capable provider plugin", async () => { + const runtime = createRuntime(); + const runTokenAuth = vi.fn().mockResolvedValue({ + profiles: [ + { + profileId: "moonshot:token", + credential: { + type: "token", + provider: "moonshot", + token: "moonshot-token", + }, + }, + ], + }); + mocks.resolvePluginProviders.mockReturnValue([ + { + id: "moonshot", + label: "Moonshot", + auth: [ + { + id: "setup-token", + label: "setup-token", + kind: "token", + run: runTokenAuth, + }, + ], + }, + ]); + + await modelsAuthSetupTokenCommand({ provider: "moonshot", yes: true }, runtime); + + expect(runTokenAuth).toHaveBeenCalledOnce(); + expect(mocks.upsertAuthProfile).toHaveBeenCalledWith({ + profileId: "moonshot:token", + credential: { + type: "token", + provider: "moonshot", + token: "moonshot-token", + }, + agentDir: "/tmp/openclaw/agents/main", + }); + }); }); diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index c9b54b2f753..46ad67c41ef 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -21,22 +21,21 @@ import { normalizeProviderId } from "../../agents/model-selection.js"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { formatCliCommand } from "../../cli/command-format.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; +import type { OpenClawConfig } from "../../config/config.js"; import { logConfigUpdated } from "../../config/logging.js"; import { resolvePluginProviders } from "../../plugins/providers.js"; -import type { ProviderAuthResult, ProviderPlugin } from "../../plugins/types.js"; +import type { + ProviderAuthMethod, + ProviderAuthResult, + ProviderPlugin, +} from "../../plugins/types.js"; import type { RuntimeEnv } from "../../runtime.js"; import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; -import { validateAnthropicSetupToken } from "../auth-token.js"; import { isRemoteEnvironment } from "../oauth-env.js"; import { createVpsAwareOAuthHandlers } from "../oauth-flow.js"; -import { applyAuthProfileConfig, writeOAuthCredentials } from "../onboard-auth.js"; +import { applyAuthProfileConfig } from "../onboard-auth.js"; import { openUrl } from "../onboard-helpers.js"; -import { - applyOpenAICodexModelDefault, - OPENAI_CODEX_DEFAULT_MODEL, -} from "../openai-codex-model-default.js"; -import { loginOpenAICodexOAuth } from "../openai-codex-oauth.js"; import { applyDefaultModel, mergeConfigPatch, @@ -78,40 +77,250 @@ const select = async (params: Parameters>[0]) => }), ); -type TokenProvider = "anthropic"; - -function resolveTokenProvider(raw?: string): TokenProvider | "custom" | null { - const trimmed = raw?.trim(); - if (!trimmed) { - return null; - } - const normalized = normalizeProviderId(trimmed); - if (normalized === "anthropic") { - return "anthropic"; - } - return "custom"; -} - function resolveDefaultTokenProfileId(provider: string): string { return `${normalizeProviderId(provider)}:manual`; } +type ResolvedModelsAuthContext = { + config: OpenClawConfig; + agentDir: string; + workspaceDir: string; + providers: ProviderPlugin[]; +}; + +function listProvidersWithAuthMethods(providers: ProviderPlugin[]): ProviderPlugin[] { + return providers.filter((provider) => provider.auth.length > 0); +} + +function listTokenAuthMethods(provider: ProviderPlugin): ProviderAuthMethod[] { + return provider.auth.filter((method) => method.kind === "token"); +} + +function listProvidersWithTokenMethods(providers: ProviderPlugin[]): ProviderPlugin[] { + return providers.filter((provider) => listTokenAuthMethods(provider).length > 0); +} + +async function resolveModelsAuthContext(): Promise { + const config = await loadValidConfigOrThrow(); + const defaultAgentId = resolveDefaultAgentId(config); + const agentDir = resolveAgentDir(config, defaultAgentId); + const workspaceDir = + resolveAgentWorkspaceDir(config, defaultAgentId) ?? resolveDefaultAgentWorkspaceDir(); + const providers = resolvePluginProviders({ config, workspaceDir }); + return { config, agentDir, workspaceDir, providers }; +} + +function resolveRequestedProviderOrThrow( + providers: ProviderPlugin[], + rawProvider?: string, +): ProviderPlugin | null { + const requested = rawProvider?.trim(); + if (!requested) { + return null; + } + const matched = resolveProviderMatch(providers, requested); + if (matched) { + return matched; + } + const available = providers + .map((provider) => provider.id) + .filter(Boolean) + .toSorted((a, b) => a.localeCompare(b)); + const availableText = available.length > 0 ? available.join(", ") : "(none)"; + throw new Error( + `Unknown provider "${requested}". Loaded providers: ${availableText}. Verify plugins via \`${formatCliCommand("openclaw plugins list --json")}\`.`, + ); +} + +function resolveTokenMethodOrThrow( + provider: ProviderPlugin, + rawMethod?: string, +): ProviderAuthMethod | null { + const tokenMethods = listTokenAuthMethods(provider); + if (rawMethod?.trim()) { + const matched = pickAuthMethod(provider, rawMethod); + if (matched && matched.kind === "token") { + return matched; + } + const available = tokenMethods.map((method) => method.id).join(", ") || "(none)"; + throw new Error( + `Unknown token auth method "${rawMethod}" for provider "${provider.id}". Available token methods: ${available}.`, + ); + } + return null; +} + +async function pickProviderAuthMethod(params: { + provider: ProviderPlugin; + requestedMethod?: string; + prompter: ReturnType; +}) { + const requestedMethod = pickAuthMethod(params.provider, params.requestedMethod); + if (requestedMethod) { + return requestedMethod; + } + if (params.provider.auth.length === 1) { + return params.provider.auth[0] ?? null; + } + return await params.prompter + .select({ + message: `Auth method for ${params.provider.label}`, + options: params.provider.auth.map((method) => ({ + value: method.id, + label: method.label, + hint: method.hint, + })), + }) + .then((id) => params.provider.auth.find((method) => method.id === String(id)) ?? null); +} + +async function pickProviderTokenMethod(params: { + provider: ProviderPlugin; + requestedMethod?: string; + prompter: ReturnType; +}) { + const explicitTokenMethod = resolveTokenMethodOrThrow(params.provider, params.requestedMethod); + if (explicitTokenMethod) { + return explicitTokenMethod; + } + const tokenMethods = listTokenAuthMethods(params.provider); + if (tokenMethods.length === 0) { + return null; + } + const setupTokenMethod = tokenMethods.find((method) => method.id === "setup-token"); + if (setupTokenMethod) { + return setupTokenMethod; + } + if (tokenMethods.length === 1) { + return tokenMethods[0] ?? null; + } + return await params.prompter + .select({ + message: `Token method for ${params.provider.label}`, + options: tokenMethods.map((method) => ({ + value: method.id, + label: method.label, + hint: method.hint, + })), + }) + .then((id) => tokenMethods.find((method) => method.id === String(id)) ?? null); +} + +async function persistProviderAuthResult(params: { + result: ProviderAuthResult; + agentDir: string; + runtime: RuntimeEnv; + prompter: ReturnType; + setDefault?: boolean; +}) { + for (const profile of params.result.profiles) { + upsertAuthProfile({ + profileId: profile.profileId, + credential: profile.credential, + agentDir: params.agentDir, + }); + } + + await updateConfig((cfg) => { + let next = cfg; + if (params.result.configPatch) { + next = mergeConfigPatch(next, params.result.configPatch); + } + for (const profile of params.result.profiles) { + next = applyAuthProfileConfig(next, { + profileId: profile.profileId, + provider: profile.credential.provider, + mode: credentialMode(profile.credential), + }); + } + if (params.setDefault && params.result.defaultModel) { + next = applyDefaultModel(next, params.result.defaultModel); + } + return next; + }); + + logConfigUpdated(params.runtime); + for (const profile of params.result.profiles) { + params.runtime.log( + `Auth profile: ${profile.profileId} (${profile.credential.provider}/${credentialMode(profile.credential)})`, + ); + } + if (params.result.defaultModel) { + params.runtime.log( + params.setDefault + ? `Default model set to ${params.result.defaultModel}` + : `Default model available: ${params.result.defaultModel} (use --set-default to apply)`, + ); + } + if (params.result.notes && params.result.notes.length > 0) { + await params.prompter.note(params.result.notes.join("\n"), "Provider notes"); + } +} + +async function runProviderAuthMethod(params: { + config: OpenClawConfig; + agentDir: string; + workspaceDir: string; + provider: ProviderPlugin; + method: ProviderAuthMethod; + runtime: RuntimeEnv; + prompter: ReturnType; + setDefault?: boolean; +}) { + await clearStaleProfileLockouts(params.provider.id, params.agentDir); + + const result = await params.method.run({ + config: params.config, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + prompter: params.prompter, + runtime: params.runtime, + isRemote: isRemoteEnvironment(), + openUrl: async (url) => { + await openUrl(url); + }, + oauth: { + createVpsAwareHandlers: (runtimeParams) => createVpsAwareOAuthHandlers(runtimeParams), + }, + }); + + await persistProviderAuthResult({ + result, + agentDir: params.agentDir, + runtime: params.runtime, + prompter: params.prompter, + setDefault: params.setDefault, + }); +} + export async function modelsAuthSetupTokenCommand( opts: { provider?: string; yes?: boolean }, runtime: RuntimeEnv, ) { - const provider = resolveTokenProvider(opts.provider ?? "anthropic"); - if (provider !== "anthropic") { - throw new Error("Only --provider anthropic is supported for setup-token."); - } - if (!process.stdin.isTTY) { throw new Error("setup-token requires an interactive TTY."); } + const { config, agentDir, workspaceDir, providers } = await resolveModelsAuthContext(); + const tokenProviders = listProvidersWithTokenMethods(providers); + if (tokenProviders.length === 0) { + throw new Error( + `No provider token-auth plugins found. Install one via \`${formatCliCommand("openclaw plugins install")}\`.`, + ); + } + + const provider = + resolveRequestedProviderOrThrow(tokenProviders, opts.provider ?? "anthropic") ?? + tokenProviders.find((candidate) => normalizeProviderId(candidate.id) === "anthropic") ?? + tokenProviders[0] ?? + null; + if (!provider) { + throw new Error("No token-capable provider is available."); + } + if (!opts.yes) { const proceed = await confirm({ - message: "Have you run `claude setup-token` and copied the token?", + message: `Continue with ${provider.label} token auth?`, initialValue: true, }); if (!proceed) { @@ -119,32 +328,21 @@ export async function modelsAuthSetupTokenCommand( } } - const tokenInput = await text({ - message: "Paste Anthropic setup-token", - validate: (value) => validateAnthropicSetupToken(String(value ?? "")), + const prompter = createClackPrompter(); + const method = await pickProviderTokenMethod({ provider, prompter }); + if (!method) { + throw new Error(`Provider "${provider.id}" does not expose a token auth method.`); + } + + await runProviderAuthMethod({ + config, + agentDir, + workspaceDir, + provider, + method, + runtime, + prompter, }); - const token = String(tokenInput ?? "").trim(); - const profileId = resolveDefaultTokenProfileId(provider); - - upsertAuthProfile({ - profileId, - credential: { - type: "token", - provider, - token, - }, - }); - - await updateConfig((cfg) => - applyAuthProfileConfig(cfg, { - profileId, - provider, - mode: "token", - }), - ); - - logConfigUpdated(runtime); - runtime.log(`Auth profile: ${profileId} (${provider}/token)`); } export async function modelsAuthPasteTokenCommand( @@ -190,10 +388,17 @@ export async function modelsAuthPasteTokenCommand( } export async function modelsAuthAddCommand(_opts: Record, runtime: RuntimeEnv) { + const { config, agentDir, workspaceDir, providers } = await resolveModelsAuthContext(); + const tokenProviders = listProvidersWithTokenMethods(providers); + const provider = await select({ message: "Token provider", options: [ - { value: "anthropic", label: "anthropic" }, + ...tokenProviders.map((providerPlugin) => ({ + value: providerPlugin.id, + label: providerPlugin.id, + hint: providerPlugin.docsPath ? `Docs: ${providerPlugin.docsPath}` : undefined, + })), { value: "custom", label: "custom (type provider id)" }, ], }); @@ -210,25 +415,41 @@ export async function modelsAuthAddCommand(_opts: Record, runtime ) : provider; - const method = (await select({ - message: "Token method", - options: [ - ...(providerId === "anthropic" - ? [ - { - value: "setup-token", - label: "setup-token (claude)", - hint: "Paste a setup-token from `claude setup-token`", - }, - ] - : []), - { value: "paste", label: "paste token" }, - ], - })) as "setup-token" | "paste"; - - if (method === "setup-token") { - await modelsAuthSetupTokenCommand({ provider: providerId }, runtime); - return; + const providerPlugin = + provider === "custom" ? null : resolveRequestedProviderOrThrow(tokenProviders, providerId); + if (providerPlugin) { + const tokenMethods = listTokenAuthMethods(providerPlugin); + const methodId = + tokenMethods.length > 0 + ? await select({ + message: "Token method", + options: [ + ...tokenMethods.map((method) => ({ + value: method.id, + label: method.label, + hint: method.hint, + })), + { value: "paste", label: "paste token" }, + ], + }) + : "paste"; + if (methodId !== "paste") { + const prompter = createClackPrompter(); + const method = tokenMethods.find((candidate) => candidate.id === methodId); + if (!method) { + throw new Error(`Unknown token auth method "${String(methodId)}".`); + } + await runProviderAuthMethod({ + config, + agentDir, + workspaceDir, + provider: providerPlugin, + method, + runtime, + prompter, + }); + return; + } } const profileIdDefault = resolveDefaultTokenProfileId(providerId); @@ -292,22 +513,7 @@ export function resolveRequestedLoginProviderOrThrow( providers: ProviderPlugin[], rawProvider?: string, ): ProviderPlugin | null { - const requested = rawProvider?.trim(); - if (!requested) { - return null; - } - const matched = resolveProviderMatch(providers, requested); - if (matched) { - return matched; - } - const available = providers - .map((provider) => provider.id) - .filter(Boolean) - .toSorted((a, b) => a.localeCompare(b)); - const availableText = available.length > 0 ? available.join(", ") : "(none)"; - throw new Error( - `Unknown provider "${requested}". Loaded providers: ${availableText}. Verify plugins via \`${formatCliCommand("openclaw plugins list --json")}\`.`, - ); + return resolveRequestedProviderOrThrow(providers, rawProvider); } function credentialMode(credential: AuthProfileCredential): "api_key" | "oauth" | "token" { @@ -320,177 +526,55 @@ function credentialMode(credential: AuthProfileCredential): "api_key" | "oauth" return "oauth"; } -async function runBuiltInOpenAICodexLogin(params: { - opts: LoginOptions; - runtime: RuntimeEnv; - prompter: ReturnType; - agentDir: string; -}) { - const creds = await loginOpenAICodexOAuth({ - prompter: params.prompter, - runtime: params.runtime, - isRemote: isRemoteEnvironment(), - openUrl: async (url) => { - await openUrl(url); - }, - localBrowserMessage: "Complete sign-in in browser…", - }); - if (!creds) { - throw new Error("OpenAI Codex OAuth did not return credentials."); - } - - const profileId = await writeOAuthCredentials("openai-codex", creds, params.agentDir, { - syncSiblingAgents: true, - }); - await updateConfig((cfg) => { - let next = applyAuthProfileConfig(cfg, { - profileId, - provider: "openai-codex", - mode: "oauth", - }); - if (params.opts.setDefault) { - next = applyOpenAICodexModelDefault(next).next; - } - return next; - }); - - logConfigUpdated(params.runtime); - params.runtime.log(`Auth profile: ${profileId} (openai-codex/oauth)`); - if (params.opts.setDefault) { - params.runtime.log(`Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`); - } else { - params.runtime.log( - `Default model available: ${OPENAI_CODEX_DEFAULT_MODEL} (use --set-default to apply)`, - ); - } -} - export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: RuntimeEnv) { if (!process.stdin.isTTY) { throw new Error("models auth login requires an interactive TTY."); } - const config = await loadValidConfigOrThrow(); - const defaultAgentId = resolveDefaultAgentId(config); - const agentDir = resolveAgentDir(config, defaultAgentId); - const workspaceDir = - resolveAgentWorkspaceDir(config, defaultAgentId) ?? resolveDefaultAgentWorkspaceDir(); - const requestedProviderId = normalizeProviderId(String(opts.provider ?? "")); + const { config, agentDir, workspaceDir, providers } = await resolveModelsAuthContext(); const prompter = createClackPrompter(); - - if (requestedProviderId === "openai-codex") { - await clearStaleProfileLockouts("openai-codex", agentDir); - await runBuiltInOpenAICodexLogin({ - opts, - runtime, - prompter, - agentDir, - }); - return; - } - - const providers = resolvePluginProviders({ config, workspaceDir }); - if (providers.length === 0) { + const authProviders = listProvidersWithAuthMethods(providers); + if (authProviders.length === 0) { throw new Error( `No provider plugins found. Install one via \`${formatCliCommand("openclaw plugins install")}\`.`, ); } - const requestedProvider = resolveRequestedLoginProviderOrThrow(providers, opts.provider); + const requestedProvider = resolveRequestedLoginProviderOrThrow(authProviders, opts.provider); const selectedProvider = requestedProvider ?? (await prompter .select({ message: "Select a provider", - options: providers.map((provider) => ({ + options: authProviders.map((provider) => ({ value: provider.id, label: provider.label, hint: provider.docsPath ? `Docs: ${provider.docsPath}` : undefined, })), }) - .then((id) => resolveProviderMatch(providers, String(id)))); + .then((id) => resolveProviderMatch(authProviders, String(id)))); if (!selectedProvider) { throw new Error("Unknown provider. Use --provider to pick a provider plugin."); } - - await clearStaleProfileLockouts(selectedProvider.id, agentDir); - - const chosenMethod = - pickAuthMethod(selectedProvider, opts.method) ?? - (selectedProvider.auth.length === 1 - ? selectedProvider.auth[0] - : await prompter - .select({ - message: `Auth method for ${selectedProvider.label}`, - options: selectedProvider.auth.map((method) => ({ - value: method.id, - label: method.label, - hint: method.hint, - })), - }) - .then((id) => selectedProvider.auth.find((method) => method.id === String(id)))); + const chosenMethod = await pickProviderAuthMethod({ + provider: selectedProvider, + requestedMethod: opts.method, + prompter, + }); if (!chosenMethod) { throw new Error("Unknown auth method. Use --method to select one."); } - const isRemote = isRemoteEnvironment(); - const result: ProviderAuthResult = await chosenMethod.run({ + await runProviderAuthMethod({ config, agentDir, workspaceDir, - prompter, + provider: selectedProvider, + method: chosenMethod, runtime, - isRemote, - openUrl: async (url) => { - await openUrl(url); - }, - oauth: { - createVpsAwareHandlers: (params) => createVpsAwareOAuthHandlers(params), - }, + prompter, + setDefault: opts.setDefault, }); - - for (const profile of result.profiles) { - upsertAuthProfile({ - profileId: profile.profileId, - credential: profile.credential, - agentDir, - }); - } - - await updateConfig((cfg) => { - let next = cfg; - if (result.configPatch) { - next = mergeConfigPatch(next, result.configPatch); - } - for (const profile of result.profiles) { - next = applyAuthProfileConfig(next, { - profileId: profile.profileId, - provider: profile.credential.provider, - mode: credentialMode(profile.credential), - }); - } - if (opts.setDefault && result.defaultModel) { - next = applyDefaultModel(next, result.defaultModel); - } - return next; - }); - - logConfigUpdated(runtime); - for (const profile of result.profiles) { - runtime.log( - `Auth profile: ${profile.profileId} (${profile.credential.provider}/${credentialMode(profile.credential)})`, - ); - } - if (result.defaultModel) { - runtime.log( - opts.setDefault - ? `Default model set to ${result.defaultModel}` - : `Default model available: ${result.defaultModel} (use --set-default to apply)`, - ); - } - if (result.notes && result.notes.length > 0) { - await prompter.note(result.notes.join("\n"), "Provider notes"); - } } diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index a792af23816..f3a6d1ca16b 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -10,7 +10,9 @@ export type { ProviderBuiltInModelSuppressionResult, ProviderBuildMissingAuthMessageContext, ProviderCacheTtlEligibilityContext, + ProviderDefaultThinkingPolicyContext, ProviderFetchUsageSnapshotContext, + ProviderModernModelPolicyContext, ProviderPreparedRuntimeAuth, ProviderResolvedUsageAuth, ProviderPrepareExtraParamsContext, @@ -20,6 +22,7 @@ export type { ProviderResolveDynamicModelContext, ProviderNormalizeResolvedModelContext, ProviderRuntimeModel, + ProviderThinkingPolicyContext, ProviderWrapStreamFnContext, OpenClawPluginService, ProviderAuthContext, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index ba5583d2c4a..6ad093eec91 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -114,7 +114,9 @@ export type { ProviderBuiltInModelSuppressionResult, ProviderBuildMissingAuthMessageContext, ProviderCacheTtlEligibilityContext, + ProviderDefaultThinkingPolicyContext, ProviderFetchUsageSnapshotContext, + ProviderModernModelPolicyContext, ProviderPreparedRuntimeAuth, ProviderResolvedUsageAuth, ProviderPrepareExtraParamsContext, @@ -124,6 +126,7 @@ export type { ProviderResolveDynamicModelContext, ProviderNormalizeResolvedModelContext, ProviderRuntimeModel, + ProviderThinkingPolicyContext, ProviderWrapStreamFnContext, } from "../plugins/types.js"; export type { diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index e38d6553080..23234be8109 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -17,10 +17,14 @@ import { buildProviderMissingAuthMessageWithPlugin, prepareProviderExtraParams, resolveProviderCacheTtlEligibility, + resolveProviderBinaryThinking, resolveProviderBuiltInModelSuppression, + resolveProviderDefaultThinkingLevel, + resolveProviderModernModelRef, resolveProviderUsageSnapshotWithPlugin, resolveProviderCapabilitiesWithPlugin, resolveProviderUsageAuthWithPlugin, + resolveProviderXHighThinking, normalizeProviderResolvedModelWithPlugin, prepareProviderDynamicModel, prepareProviderRuntimeAuth, @@ -143,6 +147,10 @@ describe("provider-runtime", () => { resolveUsageAuth, fetchUsageSnapshot, isCacheTtlEligible: ({ modelId }) => modelId.startsWith("anthropic/"), + isBinaryThinking: () => true, + supportsXHighThinking: ({ modelId }) => modelId === "gpt-5.4", + resolveDefaultThinkingLevel: ({ reasoning }) => (reasoning ? "low" : "off"), + isModernModelRef: ({ modelId }) => modelId.startsWith("gpt-5"), }, ]; }); @@ -278,6 +286,47 @@ describe("provider-runtime", () => { }), ).toBe(true); + expect( + resolveProviderBinaryThinking({ + provider: "demo", + context: { + provider: "demo", + modelId: "glm-5", + }, + }), + ).toBe(true); + + expect( + resolveProviderXHighThinking({ + provider: "demo", + context: { + provider: "demo", + modelId: "gpt-5.4", + }, + }), + ).toBe(true); + + expect( + resolveProviderDefaultThinkingLevel({ + provider: "demo", + context: { + provider: "demo", + modelId: "gpt-5.4", + reasoning: true, + }, + }), + ).toBe("low"); + + expect( + resolveProviderModernModelRef({ + provider: "demo", + context: { + provider: "demo", + modelId: "gpt-5.4", + }, + }), + ).toBe(true); + expect( buildProviderMissingAuthMessageWithPlugin({ provider: "openai", diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 9e5104f7f86..8997011a7c9 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -6,7 +6,9 @@ import type { ProviderBuildMissingAuthMessageContext, ProviderBuiltInModelSuppressionContext, ProviderCacheTtlEligibilityContext, + ProviderDefaultThinkingPolicyContext, ProviderFetchUsageSnapshotContext, + ProviderModernModelPolicyContext, ProviderPrepareExtraParamsContext, ProviderPrepareDynamicModelContext, ProviderPrepareRuntimeAuthContext, @@ -14,6 +16,7 @@ import type { ProviderPlugin, ProviderResolveDynamicModelContext, ProviderRuntimeModel, + ProviderThinkingPolicyContext, ProviderWrapStreamFnContext, } from "./types.js"; @@ -179,6 +182,46 @@ export function resolveProviderCacheTtlEligibility(params: { return resolveProviderRuntimePlugin(params)?.isCacheTtlEligible?.(params.context); } +export function resolveProviderBinaryThinking(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderThinkingPolicyContext; +}) { + return resolveProviderRuntimePlugin(params)?.isBinaryThinking?.(params.context); +} + +export function resolveProviderXHighThinking(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderThinkingPolicyContext; +}) { + return resolveProviderRuntimePlugin(params)?.supportsXHighThinking?.(params.context); +} + +export function resolveProviderDefaultThinkingLevel(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderDefaultThinkingPolicyContext; +}) { + return resolveProviderRuntimePlugin(params)?.resolveDefaultThinkingLevel?.(params.context); +} + +export function resolveProviderModernModelRef(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderModernModelPolicyContext; +}) { + return resolveProviderRuntimePlugin(params)?.isModernModelRef?.(params.context); +} + export function buildProviderMissingAuthMessageWithPlugin(params: { provider: string; config?: OpenClawConfig; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 685858a9b6e..df7e00734d5 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -426,6 +426,40 @@ export type ProviderBuiltInModelSuppressionResult = { errorMessage?: string; }; +/** + * Provider-owned thinking policy input. + * + * Used by shared `/think`, ACP controls, and directive parsing to ask a + * provider whether a model supports special reasoning UX such as xhigh or a + * binary on/off toggle. + */ +export type ProviderThinkingPolicyContext = { + provider: string; + modelId: string; +}; + +/** + * Provider-owned default thinking policy input. + * + * `reasoning` is the merged catalog hint for the selected model when one is + * available. Providers can use it to keep "reasoning model => low" behavior + * without re-reading the catalog themselves. + */ +export type ProviderDefaultThinkingPolicyContext = ProviderThinkingPolicyContext & { + reasoning?: boolean; +}; + +/** + * Provider-owned "modern model" policy input. + * + * Live smoke/model-profile selection uses this to keep provider-specific + * inclusion/exclusion rules out of core. + */ +export type ProviderModernModelPolicyContext = { + provider: string; + modelId: string; +}; + /** * Final catalog augmentation hook. * @@ -651,6 +685,35 @@ export type ProviderPlugin = { | Promise | ReadonlyArray | null | undefined> | null | undefined; + /** + * Provider-owned binary thinking toggle. + * + * Return true when the provider exposes a coarse on/off reasoning control + * instead of the normal multi-level ladder shown by `/think`. + */ + isBinaryThinking?: (ctx: ProviderThinkingPolicyContext) => boolean | undefined; + /** + * Provider-owned xhigh reasoning support. + * + * Return true only for models that should expose the `xhigh` thinking level. + */ + supportsXHighThinking?: (ctx: ProviderThinkingPolicyContext) => boolean | undefined; + /** + * Provider-owned default thinking level. + * + * Use this to keep model-family defaults (for example Claude 4.6 => + * adaptive) out of core command logic. + */ + resolveDefaultThinkingLevel?: ( + ctx: ProviderDefaultThinkingPolicyContext, + ) => "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive" | null | undefined; + /** + * Provider-owned "modern model" matcher used by live profile/smoke filters. + * + * Return true when the given provider/model ref should be treated as a + * preferred modern model candidate. + */ + isModernModelRef?: (ctx: ProviderModernModelPolicyContext) => boolean | undefined; wizard?: ProviderPluginWizard; formatApiKey?: (cred: AuthProfileCredential) => string; refreshOAuth?: (cred: OAuthCredential) => Promise; From 01456f95bc5fd41243d02a33fb71abef57eb6c67 Mon Sep 17 00:00:00 2001 From: Christopher Chamaletsos Date: Sun, 15 Mar 2026 20:21:04 +0200 Subject: [PATCH 171/943] fix: control UI sends correct provider prefix when switching models The model selector was using just the model ID (e.g. "gpt-5.2") as the option value. When sent to sessions.patch, the server would fall back to the session's current provider ("anthropic") yielding "anthropic/gpt-5.2" instead of "openai/gpt-5.2". Now option values use "provider/model" format, and resolveModelOverrideValue and resolveDefaultModelValue also return the full provider-prefixed key so selected state stays consistent. --- ui/src/ui/app-render.helpers.ts | 19 ++++++++++++++----- ui/src/ui/types.ts | 1 + 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 77ba247a26d..db6dfc40861 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -529,16 +529,24 @@ function resolveModelOverrideValue(state: AppViewState): string { return ""; } // No local override recorded yet — fall back to server data. + // Include provider prefix so the value matches option keys (provider/model). const activeRow = resolveActiveSessionRow(state); - if (activeRow) { - return typeof activeRow.model === "string" ? activeRow.model.trim() : ""; + if (activeRow && typeof activeRow.model === "string" && activeRow.model.trim()) { + const provider = activeRow.modelProvider?.trim(); + const model = activeRow.model.trim(); + return provider ? `${provider}/${model}` : model; } return ""; } function resolveDefaultModelValue(state: AppViewState): string { - const model = state.sessionsResult?.defaults?.model; - return typeof model === "string" ? model.trim() : ""; + const defaults = state.sessionsResult?.defaults; + const model = defaults?.model; + if (typeof model !== "string" || !model.trim()) { + return ""; + } + const provider = defaults?.modelProvider?.trim(); + return provider ? `${provider}/${model.trim()}` : model.trim(); } function buildChatModelOptions( @@ -563,7 +571,8 @@ function buildChatModelOptions( for (const entry of catalog) { const provider = entry.provider?.trim(); - addOption(entry.id, provider ? `${entry.id} · ${provider}` : entry.id); + const value = provider ? `${provider}/${entry.id}` : entry.id; + addOption(value, provider ? `${entry.id} · ${provider}` : entry.id); } if (currentOverride) { diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index d9764a024e6..82c97c6744a 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -316,6 +316,7 @@ export type PresenceEntry = { }; export type GatewaySessionsDefaults = { + modelProvider: string | null; model: string | null; contextTokens: number | null; }; From d9fb50e7772177e7f739f7598401f30da5ad0bc8 Mon Sep 17 00:00:00 2001 From: Christopher Chamaletsos Date: Sun, 15 Mar 2026 21:05:24 +0200 Subject: [PATCH 172/943] =?UTF-8?q?fix:=20format=20default=20model=20label?= =?UTF-8?q?=20as=20'model=20=C2=B7=20provider'=20for=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default option showed 'Default (openai/gpt-5.2)' while individual options used the friendlier 'gpt-5.2 · openai' format. --- ui/src/ui/app-render.helpers.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index db6dfc40861..12e239cb50d 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -592,7 +592,10 @@ function renderChatModelSelect(state: AppViewState) { currentOverride, defaultModel, ); - const defaultLabel = defaultModel ? `Default (${defaultModel})` : "Default model"; + const defaultDisplay = defaultModel.includes("/") + ? `${defaultModel.slice(defaultModel.indexOf("/") + 1)} · ${defaultModel.slice(0, defaultModel.indexOf("/"))}` + : defaultModel; + const defaultLabel = defaultModel ? `Default (${defaultDisplay})` : "Default model"; const busy = state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null; const disabled = From 31e6cb0df6293fa8e46fd894b9c51c9d465457df Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:46:49 -0700 Subject: [PATCH 173/943] Nostr: break setup-surface import cycle --- extensions/nostr/src/default-relays.ts | 1 + extensions/nostr/src/nostr-bus.ts | 3 +-- extensions/nostr/src/setup-surface.ts | 3 ++- extensions/nostr/src/types.ts | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 extensions/nostr/src/default-relays.ts diff --git a/extensions/nostr/src/default-relays.ts b/extensions/nostr/src/default-relays.ts new file mode 100644 index 00000000000..f9b6be01cba --- /dev/null +++ b/extensions/nostr/src/default-relays.ts @@ -0,0 +1 @@ +export const DEFAULT_RELAYS = ["wss://relay.damus.io", "wss://nos.lol"]; diff --git a/extensions/nostr/src/nostr-bus.ts b/extensions/nostr/src/nostr-bus.ts index 0b015dad29f..f7fa1d4d94f 100644 --- a/extensions/nostr/src/nostr-bus.ts +++ b/extensions/nostr/src/nostr-bus.ts @@ -8,6 +8,7 @@ import { } from "nostr-tools"; import { decrypt, encrypt } from "nostr-tools/nip04"; import type { NostrProfile } from "./config-schema.js"; +import { DEFAULT_RELAYS } from "./default-relays.js"; import { createMetrics, createNoopMetrics, @@ -25,8 +26,6 @@ import { } from "./nostr-state-store.js"; import { createSeenTracker, type SeenTracker } from "./seen-tracker.js"; -export const DEFAULT_RELAYS = ["wss://relay.damus.io", "wss://nos.lol"]; - // ============================================================================ // Constants // ============================================================================ diff --git a/extensions/nostr/src/setup-surface.ts b/extensions/nostr/src/setup-surface.ts index 800b2705258..84c78743cb3 100644 --- a/extensions/nostr/src/setup-surface.ts +++ b/extensions/nostr/src/setup-surface.ts @@ -13,7 +13,8 @@ import type { DmPolicy } from "../../../src/config/types.js"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; -import { DEFAULT_RELAYS, getPublicKeyFromPrivate, normalizePubkey } from "./nostr-bus.js"; +import { DEFAULT_RELAYS } from "./default-relays.js"; +import { getPublicKeyFromPrivate, normalizePubkey } from "./nostr-bus.js"; import { resolveNostrAccount } from "./types.js"; const channel = "nostr" as const; diff --git a/extensions/nostr/src/types.ts b/extensions/nostr/src/types.ts index 9baf78a0ca8..e2419c44ac3 100644 --- a/extensions/nostr/src/types.ts +++ b/extensions/nostr/src/types.ts @@ -5,8 +5,8 @@ import { } from "openclaw/plugin-sdk/account-id"; import type { OpenClawConfig } from "openclaw/plugin-sdk/nostr"; import type { NostrProfile } from "./config-schema.js"; +import { DEFAULT_RELAYS } from "./default-relays.js"; import { getPublicKeyFromPrivate } from "./nostr-bus.js"; -import { DEFAULT_RELAYS } from "./nostr-bus.js"; export interface NostrAccountConfig { enabled?: boolean; From 7d5e26b4a283882787f71ef4d7151f03a2976a05 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:47:20 -0700 Subject: [PATCH 174/943] Tests: stabilize bundle MCP env on Windows --- src/plugins/bundle-mcp.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/plugins/bundle-mcp.test.ts b/src/plugins/bundle-mcp.test.ts index 122c7a83c5c..ef109f4abfb 100644 --- a/src/plugins/bundle-mcp.test.ts +++ b/src/plugins/bundle-mcp.test.ts @@ -24,11 +24,14 @@ afterEach(async () => { describe("loadEnabledBundleMcpConfig", () => { it("loads enabled Claude bundle MCP config and absolutizes relative args", async () => { - const env = captureEnv(["HOME"]); + const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]); try { const homeDir = await createTempDir("openclaw-bundle-mcp-home-"); const workspaceDir = await createTempDir("openclaw-bundle-mcp-workspace-"); process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + delete process.env.OPENCLAW_HOME; + delete process.env.OPENCLAW_STATE_DIR; const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "bundle-probe"); const serverPath = path.join(pluginRoot, "servers", "probe.mjs"); @@ -80,11 +83,14 @@ describe("loadEnabledBundleMcpConfig", () => { }); it("merges inline bundle MCP servers and skips disabled bundles", async () => { - const env = captureEnv(["HOME"]); + const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]); try { const homeDir = await createTempDir("openclaw-bundle-inline-home-"); const workspaceDir = await createTempDir("openclaw-bundle-inline-workspace-"); process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + delete process.env.OPENCLAW_HOME; + delete process.env.OPENCLAW_STATE_DIR; const enabledRoot = path.join(homeDir, ".openclaw", "extensions", "inline-enabled"); const disabledRoot = path.join(homeDir, ".openclaw", "extensions", "inline-disabled"); From 270ba54c4747e2f3b1d0c6f3c0f4f019d958e657 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:48:07 -0700 Subject: [PATCH 175/943] Status: lazy-load channel security and summaries --- src/security/audit.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/security/audit.ts b/src/security/audit.ts index d3c1337e042..b304f658d68 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -6,7 +6,7 @@ import { redactCdpUrl } from "../browser/cdp.helpers.js"; import { resolveBrowserConfig, resolveProfile } from "../browser/config.js"; import { resolveBrowserControlAuth } from "../browser/control-auth.js"; import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; -import { listChannelPlugins } from "../channels/plugins/index.js"; +import type { listChannelPlugins } from "../channels/plugins/index.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; @@ -137,6 +137,13 @@ type AuditExecutionContext = { deepProbeAuth?: { token?: string; password?: string }; }; +let channelPluginsModulePromise: Promise | undefined; + +async function loadChannelPlugins() { + channelPluginsModulePromise ??= import("../channels/plugins/index.js"); + return await channelPluginsModulePromise; +} + function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary { let critical = 0; let warn = 0; @@ -1244,7 +1251,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise Date: Sun, 15 Mar 2026 20:52:33 -0700 Subject: [PATCH 176/943] Docs: refresh generated config baseline --- docs/.generated/config-baseline.json | 2110 ++++++++++++++++++++++++- docs/.generated/config-baseline.jsonl | 173 +- 2 files changed, 2252 insertions(+), 31 deletions(-) diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index f6f854b2946..6dc7cc100f2 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -2956,6 +2956,16 @@ "tags": [], "hasChildren": true }, + { + "path": "agents.defaults.sandbox.backend", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "agents.defaults.sandbox.browser", "kind": "core", @@ -5048,6 +5058,16 @@ "tags": [], "hasChildren": true }, + { + "path": "agents.list.*.sandbox.backend", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "agents.list.*.sandbox.browser", "kind": "core", @@ -30047,6 +30067,16 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.telegram.accounts.*.actions.editForumTopic", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.telegram.accounts.*.actions.editMessage", "kind": "channel", @@ -31930,6 +31960,16 @@ "tags": [], "hasChildren": false }, + { + "path": "channels.telegram.actions.editForumTopic", + "kind": "channel", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "channels.telegram.actions.editMessage", "kind": "channel", @@ -44497,6 +44537,75 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.anthropic", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/anthropic-provider", + "help": "OpenClaw Anthropic provider plugin (plugin: anthropic)", + "hasChildren": true + }, + { + "path": "plugins.entries.anthropic.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/anthropic-provider Config", + "help": "Plugin-defined config payload for anthropic.", + "hasChildren": false + }, + { + "path": "plugins.entries.anthropic.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/anthropic-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.anthropic.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.anthropic.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.bluebubbles", "kind": "plugin", @@ -44566,6 +44675,213 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.brave", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/brave-plugin", + "help": "OpenClaw Brave plugin (plugin: brave)", + "hasChildren": true + }, + { + "path": "plugins.entries.brave.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/brave-plugin Config", + "help": "Plugin-defined config payload for brave.", + "hasChildren": false + }, + { + "path": "plugins.entries.brave.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/brave-plugin", + "hasChildren": false + }, + { + "path": "plugins.entries.brave.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.brave.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.byteplus", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/byteplus-provider", + "help": "OpenClaw BytePlus provider plugin (plugin: byteplus)", + "hasChildren": true + }, + { + "path": "plugins.entries.byteplus.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/byteplus-provider Config", + "help": "Plugin-defined config payload for byteplus.", + "hasChildren": false + }, + { + "path": "plugins.entries.byteplus.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/byteplus-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.byteplus.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.byteplus.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.cloudflare-ai-gateway", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/cloudflare-ai-gateway-provider", + "help": "OpenClaw Cloudflare AI Gateway provider plugin (plugin: cloudflare-ai-gateway)", + "hasChildren": true + }, + { + "path": "plugins.entries.cloudflare-ai-gateway.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/cloudflare-ai-gateway-provider Config", + "help": "Plugin-defined config payload for cloudflare-ai-gateway.", + "hasChildren": false + }, + { + "path": "plugins.entries.cloudflare-ai-gateway.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/cloudflare-ai-gateway-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.cloudflare-ai-gateway.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.cloudflare-ai-gateway.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.copilot-proxy", "kind": "plugin", @@ -45332,7 +45648,7 @@ "hasChildren": false }, { - "path": "plugins.entries.google-gemini-cli-auth", + "path": "plugins.entries.github-copilot", "kind": "plugin", "type": "object", "required": false, @@ -45341,12 +45657,12 @@ "tags": [ "advanced" ], - "label": "@openclaw/google-gemini-cli-auth", - "help": "OpenClaw Gemini CLI OAuth provider plugin (plugin: google-gemini-cli-auth)", + "label": "@openclaw/github-copilot-provider", + "help": "OpenClaw GitHub Copilot provider plugin (plugin: github-copilot)", "hasChildren": true }, { - "path": "plugins.entries.google-gemini-cli-auth.config", + "path": "plugins.entries.github-copilot.config", "kind": "plugin", "type": "object", "required": false, @@ -45355,12 +45671,12 @@ "tags": [ "advanced" ], - "label": "@openclaw/google-gemini-cli-auth Config", - "help": "Plugin-defined config payload for google-gemini-cli-auth.", + "label": "@openclaw/github-copilot-provider Config", + "help": "Plugin-defined config payload for github-copilot.", "hasChildren": false }, { - "path": "plugins.entries.google-gemini-cli-auth.enabled", + "path": "plugins.entries.github-copilot.enabled", "kind": "plugin", "type": "boolean", "required": false, @@ -45369,11 +45685,11 @@ "tags": [ "advanced" ], - "label": "Enable @openclaw/google-gemini-cli-auth", + "label": "Enable @openclaw/github-copilot-provider", "hasChildren": false }, { - "path": "plugins.entries.google-gemini-cli-auth.hooks", + "path": "plugins.entries.github-copilot.hooks", "kind": "plugin", "type": "object", "required": false, @@ -45387,7 +45703,76 @@ "hasChildren": true }, { - "path": "plugins.entries.google-gemini-cli-auth.hooks.allowPromptInjection", + "path": "plugins.entries.github-copilot.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.google", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/google-plugin", + "help": "OpenClaw Google plugin (plugin: google)", + "hasChildren": true + }, + { + "path": "plugins.entries.google.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/google-plugin Config", + "help": "Plugin-defined config payload for google.", + "hasChildren": false + }, + { + "path": "plugins.entries.google.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/google-plugin", + "hasChildren": false + }, + { + "path": "plugins.entries.google.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.google.hooks.allowPromptInjection", "kind": "plugin", "type": "boolean", "required": false, @@ -45469,6 +45854,75 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.huggingface", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/huggingface-provider", + "help": "OpenClaw Hugging Face provider plugin (plugin: huggingface)", + "hasChildren": true + }, + { + "path": "plugins.entries.huggingface.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/huggingface-provider Config", + "help": "Plugin-defined config payload for huggingface.", + "hasChildren": false + }, + { + "path": "plugins.entries.huggingface.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/huggingface-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.huggingface.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.huggingface.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.imessage", "kind": "plugin", @@ -45607,6 +46061,144 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.kilocode", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/kilocode-provider", + "help": "OpenClaw Kilo Gateway provider plugin (plugin: kilocode)", + "hasChildren": true + }, + { + "path": "plugins.entries.kilocode.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/kilocode-provider Config", + "help": "Plugin-defined config payload for kilocode.", + "hasChildren": false + }, + { + "path": "plugins.entries.kilocode.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/kilocode-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.kilocode.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.kilocode.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.kimi-coding", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/kimi-coding-provider", + "help": "OpenClaw Kimi Coding provider plugin (plugin: kimi-coding)", + "hasChildren": true + }, + { + "path": "plugins.entries.kimi-coding.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/kimi-coding-provider Config", + "help": "Plugin-defined config payload for kimi-coding.", + "hasChildren": false + }, + { + "path": "plugins.entries.kimi-coding.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/kimi-coding-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.kimi-coding.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.kimi-coding.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.line", "kind": "plugin", @@ -46290,7 +46882,7 @@ "hasChildren": false }, { - "path": "plugins.entries.minimax-portal-auth", + "path": "plugins.entries.minimax", "kind": "plugin", "type": "object", "required": false, @@ -46299,12 +46891,12 @@ "tags": [ "performance" ], - "label": "@openclaw/minimax-portal-auth", - "help": "OpenClaw MiniMax Portal OAuth provider plugin (plugin: minimax-portal-auth)", + "label": "@openclaw/minimax-provider", + "help": "OpenClaw MiniMax provider and OAuth plugin (plugin: minimax)", "hasChildren": true }, { - "path": "plugins.entries.minimax-portal-auth.config", + "path": "plugins.entries.minimax.config", "kind": "plugin", "type": "object", "required": false, @@ -46313,12 +46905,12 @@ "tags": [ "performance" ], - "label": "@openclaw/minimax-portal-auth Config", - "help": "Plugin-defined config payload for minimax-portal-auth.", + "label": "@openclaw/minimax-provider Config", + "help": "Plugin-defined config payload for minimax.", "hasChildren": false }, { - "path": "plugins.entries.minimax-portal-auth.enabled", + "path": "plugins.entries.minimax.enabled", "kind": "plugin", "type": "boolean", "required": false, @@ -46327,11 +46919,11 @@ "tags": [ "performance" ], - "label": "Enable @openclaw/minimax-portal-auth", + "label": "Enable @openclaw/minimax-provider", "hasChildren": false }, { - "path": "plugins.entries.minimax-portal-auth.hooks", + "path": "plugins.entries.minimax.hooks", "kind": "plugin", "type": "object", "required": false, @@ -46345,7 +46937,214 @@ "hasChildren": true }, { - "path": "plugins.entries.minimax-portal-auth.hooks.allowPromptInjection", + "path": "plugins.entries.minimax.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.mistral", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/mistral-provider", + "help": "OpenClaw Mistral provider plugin (plugin: mistral)", + "hasChildren": true + }, + { + "path": "plugins.entries.mistral.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/mistral-provider Config", + "help": "Plugin-defined config payload for mistral.", + "hasChildren": false + }, + { + "path": "plugins.entries.mistral.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/mistral-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.mistral.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.mistral.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.modelstudio", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/modelstudio-provider", + "help": "OpenClaw Model Studio provider plugin (plugin: modelstudio)", + "hasChildren": true + }, + { + "path": "plugins.entries.modelstudio.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/modelstudio-provider Config", + "help": "Plugin-defined config payload for modelstudio.", + "hasChildren": false + }, + { + "path": "plugins.entries.modelstudio.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/modelstudio-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.modelstudio.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.modelstudio.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.moonshot", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/moonshot-provider", + "help": "OpenClaw Moonshot provider plugin (plugin: moonshot)", + "hasChildren": true + }, + { + "path": "plugins.entries.moonshot.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/moonshot-provider Config", + "help": "Plugin-defined config payload for moonshot.", + "hasChildren": false + }, + { + "path": "plugins.entries.moonshot.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/moonshot-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.moonshot.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.moonshot.hooks.allowPromptInjection", "kind": "plugin", "type": "boolean", "required": false, @@ -46565,6 +47364,75 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.nvidia", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/nvidia-provider", + "help": "OpenClaw NVIDIA provider plugin (plugin: nvidia)", + "hasChildren": true + }, + { + "path": "plugins.entries.nvidia.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/nvidia-provider Config", + "help": "Plugin-defined config payload for nvidia.", + "hasChildren": false + }, + { + "path": "plugins.entries.nvidia.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/nvidia-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.nvidia.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.nvidia.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.ollama", "kind": "plugin", @@ -46703,6 +47571,587 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.openai", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/openai-provider", + "help": "OpenClaw OpenAI provider plugins (plugin: openai)", + "hasChildren": true + }, + { + "path": "plugins.entries.openai.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/openai-provider Config", + "help": "Plugin-defined config payload for openai.", + "hasChildren": false + }, + { + "path": "plugins.entries.openai.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/openai-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.openai.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.openai.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.opencode", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/opencode-provider", + "help": "OpenClaw OpenCode Zen provider plugin (plugin: opencode)", + "hasChildren": true + }, + { + "path": "plugins.entries.opencode-go", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/opencode-go-provider", + "help": "OpenClaw OpenCode Go provider plugin (plugin: opencode-go)", + "hasChildren": true + }, + { + "path": "plugins.entries.opencode-go.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/opencode-go-provider Config", + "help": "Plugin-defined config payload for opencode-go.", + "hasChildren": false + }, + { + "path": "plugins.entries.opencode-go.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/opencode-go-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.opencode-go.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.opencode-go.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.opencode.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/opencode-provider Config", + "help": "Plugin-defined config payload for opencode.", + "hasChildren": false + }, + { + "path": "plugins.entries.opencode.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/opencode-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.opencode.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.opencode.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.openrouter", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/openrouter-provider", + "help": "OpenClaw OpenRouter provider plugin (plugin: openrouter)", + "hasChildren": true + }, + { + "path": "plugins.entries.openrouter.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/openrouter-provider Config", + "help": "Plugin-defined config payload for openrouter.", + "hasChildren": false + }, + { + "path": "plugins.entries.openrouter.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/openrouter-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.openrouter.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.openrouter.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "OpenShell Sandbox", + "help": "Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. (plugin: openshell)", + "hasChildren": true + }, + { + "path": "plugins.entries.openshell.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "OpenShell Sandbox Config", + "help": "Plugin-defined config payload for openshell.", + "hasChildren": true + }, + { + "path": "plugins.entries.openshell.config.autoProviders", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Auto-create Providers", + "help": "When enabled, pass --auto-providers during sandbox create.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.config.command", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "OpenShell Command", + "help": "Path or command name for the openshell CLI.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.config.from", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Sandbox Source", + "help": "OpenShell sandbox source for first-time create. Defaults to openclaw.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.config.gateway", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Gateway Name", + "help": "Optional OpenShell gateway name passed as --gateway.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.config.gatewayEndpoint", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Gateway Endpoint", + "help": "Optional OpenShell gateway endpoint passed as --gateway-endpoint.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.config.gpu", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "GPU", + "help": "Request GPU resources when creating the sandbox.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.config.policy", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Policy File", + "help": "Optional path to a custom OpenShell sandbox policy YAML.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.config.providers", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Providers", + "help": "Provider names to attach when a sandbox is created.", + "hasChildren": true + }, + { + "path": "plugins.entries.openshell.config.providers.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.config.remoteAgentWorkspaceDir", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced", + "storage" + ], + "label": "Remote Agent Dir", + "help": "Mirror path for the real agent workspace when workspaceAccess is read-only.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.config.remoteWorkspaceDir", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced", + "storage" + ], + "label": "Remote Workspace Dir", + "help": "Primary writable workspace inside the OpenShell sandbox.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.config.timeoutSeconds", + "kind": "plugin", + "type": "number", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced", + "performance" + ], + "label": "Command Timeout Seconds", + "help": "Timeout for openshell CLI operations such as create/upload/download.", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable OpenShell Sandbox", + "hasChildren": false + }, + { + "path": "plugins.entries.openshell.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.openshell.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.perplexity", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/perplexity-plugin", + "help": "OpenClaw Perplexity plugin (plugin: perplexity)", + "hasChildren": true + }, + { + "path": "plugins.entries.perplexity.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/perplexity-plugin Config", + "help": "Plugin-defined config payload for perplexity.", + "hasChildren": false + }, + { + "path": "plugins.entries.perplexity.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/perplexity-plugin", + "hasChildren": false + }, + { + "path": "plugins.entries.perplexity.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.perplexity.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.phone-control", "kind": "plugin", @@ -46772,6 +48221,75 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.qianfan", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/qianfan-provider", + "help": "OpenClaw Qianfan provider plugin (plugin: qianfan)", + "hasChildren": true + }, + { + "path": "plugins.entries.qianfan.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/qianfan-provider Config", + "help": "Plugin-defined config payload for qianfan.", + "hasChildren": false + }, + { + "path": "plugins.entries.qianfan.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/qianfan-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.qianfan.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.qianfan.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.qwen-portal-auth", "kind": "plugin", @@ -47117,6 +48635,75 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.synthetic", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/synthetic-provider", + "help": "OpenClaw Synthetic provider plugin (plugin: synthetic)", + "hasChildren": true + }, + { + "path": "plugins.entries.synthetic.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/synthetic-provider Config", + "help": "Plugin-defined config payload for synthetic.", + "hasChildren": false + }, + { + "path": "plugins.entries.synthetic.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/synthetic-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.synthetic.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.synthetic.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.talk-voice", "kind": "plugin", @@ -47431,6 +49018,75 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.together", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/together-provider", + "help": "OpenClaw Together provider plugin (plugin: together)", + "hasChildren": true + }, + { + "path": "plugins.entries.together.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/together-provider Config", + "help": "Plugin-defined config payload for together.", + "hasChildren": false + }, + { + "path": "plugins.entries.together.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/together-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.together.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.together.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.twitch", "kind": "plugin", @@ -47500,6 +49156,144 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.venice", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/venice-provider", + "help": "OpenClaw Venice provider plugin (plugin: venice)", + "hasChildren": true + }, + { + "path": "plugins.entries.venice.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/venice-provider Config", + "help": "Plugin-defined config payload for venice.", + "hasChildren": false + }, + { + "path": "plugins.entries.venice.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/venice-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.venice.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.venice.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.vercel-ai-gateway", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/vercel-ai-gateway-provider", + "help": "OpenClaw Vercel AI Gateway provider plugin (plugin: vercel-ai-gateway)", + "hasChildren": true + }, + { + "path": "plugins.entries.vercel-ai-gateway.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/vercel-ai-gateway-provider Config", + "help": "Plugin-defined config payload for vercel-ai-gateway.", + "hasChildren": false + }, + { + "path": "plugins.entries.vercel-ai-gateway.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/vercel-ai-gateway-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.vercel-ai-gateway.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.vercel-ai-gateway.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.vllm", "kind": "plugin", @@ -48999,6 +50793,75 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.volcengine", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/volcengine-provider", + "help": "OpenClaw Volcengine provider plugin (plugin: volcengine)", + "hasChildren": true + }, + { + "path": "plugins.entries.volcengine.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/volcengine-provider Config", + "help": "Plugin-defined config payload for volcengine.", + "hasChildren": false + }, + { + "path": "plugins.entries.volcengine.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/volcengine-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.volcengine.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.volcengine.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.whatsapp", "kind": "plugin", @@ -49068,6 +50931,213 @@ "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "hasChildren": false }, + { + "path": "plugins.entries.xai", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/xai-plugin", + "help": "OpenClaw xAI plugin (plugin: xai)", + "hasChildren": true + }, + { + "path": "plugins.entries.xai.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/xai-plugin Config", + "help": "Plugin-defined config payload for xai.", + "hasChildren": false + }, + { + "path": "plugins.entries.xai.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/xai-plugin", + "hasChildren": false + }, + { + "path": "plugins.entries.xai.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.xai.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.xiaomi", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/xiaomi-provider", + "help": "OpenClaw Xiaomi provider plugin (plugin: xiaomi)", + "hasChildren": true + }, + { + "path": "plugins.entries.xiaomi.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/xiaomi-provider Config", + "help": "Plugin-defined config payload for xiaomi.", + "hasChildren": false + }, + { + "path": "plugins.entries.xiaomi.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/xiaomi-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.xiaomi.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.xiaomi.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.zai", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/zai-provider", + "help": "OpenClaw Z.AI provider plugin (plugin: zai)", + "hasChildren": true + }, + { + "path": "plugins.entries.zai.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/zai-provider Config", + "help": "Plugin-defined config payload for zai.", + "hasChildren": false + }, + { + "path": "plugins.entries.zai.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/zai-provider", + "hasChildren": false + }, + { + "path": "plugins.entries.zai.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.zai.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, { "path": "plugins.entries.zalo", "kind": "plugin", diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index 18baeac12b9..65552724518 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -1,4 +1,4 @@ -{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":4889} +{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5040} {"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -245,6 +245,7 @@ {"recordType":"path","path":"agents.defaults.pdfModel.primary","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"PDF Model","help":"Optional PDF model (provider/model) for the PDF analysis tool. Defaults to imageModel, then session model.","hasChildren":false} {"recordType":"path","path":"agents.defaults.repoRoot","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Repo Root","help":"Optional repository root shown in the system prompt runtime line (overrides auto-detect).","hasChildren":false} {"recordType":"path","path":"agents.defaults.sandbox","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"agents.defaults.sandbox.backend","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.defaults.sandbox.browser","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.defaults.sandbox.browser.allowHostControl","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.defaults.sandbox.browser.autoStart","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -445,6 +446,7 @@ {"recordType":"path","path":"agents.list.*.runtime.acp.mode","kind":"core","type":"string","required":false,"enumValues":["persistent","oneshot"],"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent ACP Mode","help":"Optional ACP session mode default for this agent (persistent or oneshot).","hasChildren":false} {"recordType":"path","path":"agents.list.*.runtime.type","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent Runtime Type","help":"Runtime type for this agent: \"embedded\" (default OpenClaw runtime) or \"acp\" (ACP harness defaults).","hasChildren":false} {"recordType":"path","path":"agents.list.*.sandbox","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"agents.list.*.sandbox.backend","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.sandbox.browser","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.list.*.sandbox.browser.allowHostControl","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.sandbox.browser.autoStart","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -2708,6 +2710,7 @@ {"recordType":"path","path":"channels.telegram.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.telegram.accounts.*.actions.createForumTopic","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.actions.deleteMessage","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.telegram.accounts.*.actions.editForumTopic","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.actions.editMessage","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.actions.poll","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.accounts.*.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -2883,6 +2886,7 @@ {"recordType":"path","path":"channels.telegram.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"channels.telegram.actions.createForumTopic","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.actions.deleteMessage","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"channels.telegram.actions.editForumTopic","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.actions.editMessage","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.actions.poll","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"channels.telegram.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -3940,11 +3944,31 @@ {"recordType":"path","path":"plugins.entries.acpx.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable ACPX Runtime","hasChildren":false} {"recordType":"path","path":"plugins.entries.acpx.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.acpx.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.anthropic","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/anthropic-provider","help":"OpenClaw Anthropic provider plugin (plugin: anthropic)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.anthropic.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/anthropic-provider Config","help":"Plugin-defined config payload for anthropic.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.anthropic.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/anthropic-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.anthropic.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.anthropic.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.bluebubbles","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/bluebubbles","help":"OpenClaw BlueBubbles channel plugin (plugin: bluebubbles)","hasChildren":true} {"recordType":"path","path":"plugins.entries.bluebubbles.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/bluebubbles Config","help":"Plugin-defined config payload for bluebubbles.","hasChildren":false} {"recordType":"path","path":"plugins.entries.bluebubbles.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/bluebubbles","hasChildren":false} {"recordType":"path","path":"plugins.entries.bluebubbles.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.bluebubbles.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.brave","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/brave-plugin","help":"OpenClaw Brave plugin (plugin: brave)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.brave.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/brave-plugin Config","help":"Plugin-defined config payload for brave.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.brave.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/brave-plugin","hasChildren":false} +{"recordType":"path","path":"plugins.entries.brave.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.brave.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.byteplus","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/byteplus-provider","help":"OpenClaw BytePlus provider plugin (plugin: byteplus)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.byteplus.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/byteplus-provider Config","help":"Plugin-defined config payload for byteplus.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.byteplus.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/byteplus-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.byteplus.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.byteplus.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/cloudflare-ai-gateway-provider","help":"OpenClaw Cloudflare AI Gateway provider plugin (plugin: cloudflare-ai-gateway)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/cloudflare-ai-gateway-provider Config","help":"Plugin-defined config payload for cloudflare-ai-gateway.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/cloudflare-ai-gateway-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.copilot-proxy","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/copilot-proxy","help":"OpenClaw Copilot Proxy provider plugin (plugin: copilot-proxy)","hasChildren":true} {"recordType":"path","path":"plugins.entries.copilot-proxy.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/copilot-proxy Config","help":"Plugin-defined config payload for copilot-proxy.","hasChildren":false} {"recordType":"path","path":"plugins.entries.copilot-proxy.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/copilot-proxy","hasChildren":false} @@ -3998,16 +4022,26 @@ {"recordType":"path","path":"plugins.entries.feishu.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/feishu","hasChildren":false} {"recordType":"path","path":"plugins.entries.feishu.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.feishu.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} -{"recordType":"path","path":"plugins.entries.google-gemini-cli-auth","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/google-gemini-cli-auth","help":"OpenClaw Gemini CLI OAuth provider plugin (plugin: google-gemini-cli-auth)","hasChildren":true} -{"recordType":"path","path":"plugins.entries.google-gemini-cli-auth.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/google-gemini-cli-auth Config","help":"Plugin-defined config payload for google-gemini-cli-auth.","hasChildren":false} -{"recordType":"path","path":"plugins.entries.google-gemini-cli-auth.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/google-gemini-cli-auth","hasChildren":false} -{"recordType":"path","path":"plugins.entries.google-gemini-cli-auth.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} -{"recordType":"path","path":"plugins.entries.google-gemini-cli-auth.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.github-copilot","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/github-copilot-provider","help":"OpenClaw GitHub Copilot provider plugin (plugin: github-copilot)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.github-copilot.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/github-copilot-provider Config","help":"Plugin-defined config payload for github-copilot.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.github-copilot.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/github-copilot-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.github-copilot.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.github-copilot.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.google","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/google-plugin","help":"OpenClaw Google plugin (plugin: google)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.google.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/google-plugin Config","help":"Plugin-defined config payload for google.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.google.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/google-plugin","hasChildren":false} +{"recordType":"path","path":"plugins.entries.google.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.google.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.googlechat","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/googlechat","help":"OpenClaw Google Chat channel plugin (plugin: googlechat)","hasChildren":true} {"recordType":"path","path":"plugins.entries.googlechat.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/googlechat Config","help":"Plugin-defined config payload for googlechat.","hasChildren":false} {"recordType":"path","path":"plugins.entries.googlechat.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/googlechat","hasChildren":false} {"recordType":"path","path":"plugins.entries.googlechat.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.googlechat.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.huggingface","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/huggingface-provider","help":"OpenClaw Hugging Face provider plugin (plugin: huggingface)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.huggingface.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/huggingface-provider Config","help":"Plugin-defined config payload for huggingface.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.huggingface.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/huggingface-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.huggingface.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.huggingface.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.imessage","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/imessage","help":"OpenClaw iMessage channel plugin (plugin: imessage)","hasChildren":true} {"recordType":"path","path":"plugins.entries.imessage.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/imessage Config","help":"Plugin-defined config payload for imessage.","hasChildren":false} {"recordType":"path","path":"plugins.entries.imessage.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/imessage","hasChildren":false} @@ -4018,6 +4052,16 @@ {"recordType":"path","path":"plugins.entries.irc.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/irc","hasChildren":false} {"recordType":"path","path":"plugins.entries.irc.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.irc.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.kilocode","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/kilocode-provider","help":"OpenClaw Kilo Gateway provider plugin (plugin: kilocode)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.kilocode.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/kilocode-provider Config","help":"Plugin-defined config payload for kilocode.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.kilocode.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/kilocode-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.kilocode.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.kilocode.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.kimi-coding","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/kimi-coding-provider","help":"OpenClaw Kimi Coding provider plugin (plugin: kimi-coding)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.kimi-coding.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/kimi-coding-provider Config","help":"Plugin-defined config payload for kimi-coding.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.kimi-coding.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/kimi-coding-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.kimi-coding.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.kimi-coding.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.line","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/line","help":"OpenClaw LINE channel plugin (plugin: line)","hasChildren":true} {"recordType":"path","path":"plugins.entries.line.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/line Config","help":"Plugin-defined config payload for line.","hasChildren":false} {"recordType":"path","path":"plugins.entries.line.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/line","hasChildren":false} @@ -4069,11 +4113,26 @@ {"recordType":"path","path":"plugins.entries.memory-lancedb.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Enable @openclaw/memory-lancedb","hasChildren":false} {"recordType":"path","path":"plugins.entries.memory-lancedb.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.memory-lancedb.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} -{"recordType":"path","path":"plugins.entries.minimax-portal-auth","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"@openclaw/minimax-portal-auth","help":"OpenClaw MiniMax Portal OAuth provider plugin (plugin: minimax-portal-auth)","hasChildren":true} -{"recordType":"path","path":"plugins.entries.minimax-portal-auth.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"@openclaw/minimax-portal-auth Config","help":"Plugin-defined config payload for minimax-portal-auth.","hasChildren":false} -{"recordType":"path","path":"plugins.entries.minimax-portal-auth.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Enable @openclaw/minimax-portal-auth","hasChildren":false} -{"recordType":"path","path":"plugins.entries.minimax-portal-auth.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} -{"recordType":"path","path":"plugins.entries.minimax-portal-auth.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.minimax","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"@openclaw/minimax-provider","help":"OpenClaw MiniMax provider and OAuth plugin (plugin: minimax)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.minimax.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"@openclaw/minimax-provider Config","help":"Plugin-defined config payload for minimax.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.minimax.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Enable @openclaw/minimax-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.minimax.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.minimax.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.mistral","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/mistral-provider","help":"OpenClaw Mistral provider plugin (plugin: mistral)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.mistral.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/mistral-provider Config","help":"Plugin-defined config payload for mistral.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.mistral.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/mistral-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.mistral.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.mistral.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.modelstudio","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/modelstudio-provider","help":"OpenClaw Model Studio provider plugin (plugin: modelstudio)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.modelstudio.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/modelstudio-provider Config","help":"Plugin-defined config payload for modelstudio.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.modelstudio.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/modelstudio-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.modelstudio.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.modelstudio.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.moonshot","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/moonshot-provider","help":"OpenClaw Moonshot provider plugin (plugin: moonshot)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.moonshot.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/moonshot-provider Config","help":"Plugin-defined config payload for moonshot.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.moonshot.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/moonshot-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.moonshot.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.moonshot.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.msteams","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/msteams","help":"OpenClaw Microsoft Teams channel plugin (plugin: msteams)","hasChildren":true} {"recordType":"path","path":"plugins.entries.msteams.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/msteams Config","help":"Plugin-defined config payload for msteams.","hasChildren":false} {"recordType":"path","path":"plugins.entries.msteams.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/msteams","hasChildren":false} @@ -4089,6 +4148,11 @@ {"recordType":"path","path":"plugins.entries.nostr.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/nostr","hasChildren":false} {"recordType":"path","path":"plugins.entries.nostr.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.nostr.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.nvidia","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/nvidia-provider","help":"OpenClaw NVIDIA provider plugin (plugin: nvidia)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.nvidia.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/nvidia-provider Config","help":"Plugin-defined config payload for nvidia.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.nvidia.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/nvidia-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.nvidia.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.nvidia.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.ollama","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/ollama-provider","help":"OpenClaw Ollama provider plugin (plugin: ollama)","hasChildren":true} {"recordType":"path","path":"plugins.entries.ollama.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/ollama-provider Config","help":"Plugin-defined config payload for ollama.","hasChildren":false} {"recordType":"path","path":"plugins.entries.ollama.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/ollama-provider","hasChildren":false} @@ -4099,11 +4163,58 @@ {"recordType":"path","path":"plugins.entries.open-prose.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable OpenProse","hasChildren":false} {"recordType":"path","path":"plugins.entries.open-prose.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.open-prose.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openai","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/openai-provider","help":"OpenClaw OpenAI provider plugins (plugin: openai)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openai.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/openai-provider Config","help":"Plugin-defined config payload for openai.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openai.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/openai-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openai.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openai.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.opencode","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/opencode-provider","help":"OpenClaw OpenCode Zen provider plugin (plugin: opencode)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.opencode-go","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/opencode-go-provider","help":"OpenClaw OpenCode Go provider plugin (plugin: opencode-go)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.opencode-go.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/opencode-go-provider Config","help":"Plugin-defined config payload for opencode-go.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.opencode-go.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/opencode-go-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.opencode-go.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.opencode-go.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.opencode.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/opencode-provider Config","help":"Plugin-defined config payload for opencode.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.opencode.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/opencode-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.opencode.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.opencode.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openrouter","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/openrouter-provider","help":"OpenClaw OpenRouter provider plugin (plugin: openrouter)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openrouter.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/openrouter-provider Config","help":"Plugin-defined config payload for openrouter.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openrouter.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/openrouter-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openrouter.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openrouter.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"OpenShell Sandbox","help":"Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. (plugin: openshell)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openshell.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"OpenShell Sandbox Config","help":"Plugin-defined config payload for openshell.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openshell.config.autoProviders","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Auto-create Providers","help":"When enabled, pass --auto-providers during sandbox create.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.config.command","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"OpenShell Command","help":"Path or command name for the openshell CLI.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.config.from","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Sandbox Source","help":"OpenShell sandbox source for first-time create. Defaults to openclaw.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.config.gateway","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Gateway Name","help":"Optional OpenShell gateway name passed as --gateway.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.config.gatewayEndpoint","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Gateway Endpoint","help":"Optional OpenShell gateway endpoint passed as --gateway-endpoint.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.config.gpu","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"GPU","help":"Request GPU resources when creating the sandbox.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.config.policy","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Policy File","help":"Optional path to a custom OpenShell sandbox policy YAML.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.config.providers","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Providers","help":"Provider names to attach when a sandbox is created.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openshell.config.providers.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.config.remoteAgentWorkspaceDir","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced","storage"],"label":"Remote Agent Dir","help":"Mirror path for the real agent workspace when workspaceAccess is read-only.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.config.remoteWorkspaceDir","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced","storage"],"label":"Remote Workspace Dir","help":"Primary writable workspace inside the OpenShell sandbox.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.config.timeoutSeconds","kind":"plugin","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["advanced","performance"],"label":"Command Timeout Seconds","help":"Timeout for openshell CLI operations such as create/upload/download.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable OpenShell Sandbox","hasChildren":false} +{"recordType":"path","path":"plugins.entries.openshell.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.openshell.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.perplexity","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/perplexity-plugin","help":"OpenClaw Perplexity plugin (plugin: perplexity)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.perplexity.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/perplexity-plugin Config","help":"Plugin-defined config payload for perplexity.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.perplexity.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/perplexity-plugin","hasChildren":false} +{"recordType":"path","path":"plugins.entries.perplexity.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.perplexity.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.phone-control","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Phone Control","help":"Arm/disarm high-risk phone node commands (camera/screen/writes) with an optional auto-expiry. (plugin: phone-control)","hasChildren":true} {"recordType":"path","path":"plugins.entries.phone-control.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Phone Control Config","help":"Plugin-defined config payload for phone-control.","hasChildren":false} {"recordType":"path","path":"plugins.entries.phone-control.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable Phone Control","hasChildren":false} {"recordType":"path","path":"plugins.entries.phone-control.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.phone-control.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.qianfan","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/qianfan-provider","help":"OpenClaw Qianfan provider plugin (plugin: qianfan)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.qianfan.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/qianfan-provider Config","help":"Plugin-defined config payload for qianfan.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.qianfan.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/qianfan-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.qianfan.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.qianfan.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.qwen-portal-auth","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"qwen-portal-auth","help":"Plugin entry for qwen-portal-auth.","hasChildren":true} {"recordType":"path","path":"plugins.entries.qwen-portal-auth.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"qwen-portal-auth Config","help":"Plugin-defined config payload for qwen-portal-auth.","hasChildren":false} {"recordType":"path","path":"plugins.entries.qwen-portal-auth.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable qwen-portal-auth","hasChildren":false} @@ -4129,6 +4240,11 @@ {"recordType":"path","path":"plugins.entries.synology-chat.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/synology-chat","hasChildren":false} {"recordType":"path","path":"plugins.entries.synology-chat.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.synology-chat.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.synthetic","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/synthetic-provider","help":"OpenClaw Synthetic provider plugin (plugin: synthetic)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.synthetic.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/synthetic-provider Config","help":"Plugin-defined config payload for synthetic.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.synthetic.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/synthetic-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.synthetic.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.synthetic.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.talk-voice","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Talk Voice","help":"Manage Talk voice selection (list/set). (plugin: talk-voice)","hasChildren":true} {"recordType":"path","path":"plugins.entries.talk-voice.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Talk Voice Config","help":"Plugin-defined config payload for talk-voice.","hasChildren":false} {"recordType":"path","path":"plugins.entries.talk-voice.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable Talk Voice","hasChildren":false} @@ -4152,11 +4268,26 @@ {"recordType":"path","path":"plugins.entries.tlon.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/tlon","hasChildren":false} {"recordType":"path","path":"plugins.entries.tlon.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.tlon.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.together","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/together-provider","help":"OpenClaw Together provider plugin (plugin: together)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.together.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/together-provider Config","help":"Plugin-defined config payload for together.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.together.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/together-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.together.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.together.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.twitch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/twitch","help":"OpenClaw Twitch channel plugin (plugin: twitch)","hasChildren":true} {"recordType":"path","path":"plugins.entries.twitch.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/twitch Config","help":"Plugin-defined config payload for twitch.","hasChildren":false} {"recordType":"path","path":"plugins.entries.twitch.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/twitch","hasChildren":false} {"recordType":"path","path":"plugins.entries.twitch.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.twitch.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.venice","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/venice-provider","help":"OpenClaw Venice provider plugin (plugin: venice)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.venice.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/venice-provider Config","help":"Plugin-defined config payload for venice.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.venice.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/venice-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.venice.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.venice.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.vercel-ai-gateway","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/vercel-ai-gateway-provider","help":"OpenClaw Vercel AI Gateway provider plugin (plugin: vercel-ai-gateway)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.vercel-ai-gateway.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/vercel-ai-gateway-provider Config","help":"Plugin-defined config payload for vercel-ai-gateway.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.vercel-ai-gateway.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/vercel-ai-gateway-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.vercel-ai-gateway.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.vercel-ai-gateway.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.vllm","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/vllm-provider","help":"OpenClaw vLLM provider plugin (plugin: vllm)","hasChildren":true} {"recordType":"path","path":"plugins.entries.vllm.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/vllm-provider Config","help":"Plugin-defined config payload for vllm.","hasChildren":false} {"recordType":"path","path":"plugins.entries.vllm.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/vllm-provider","hasChildren":false} @@ -4283,11 +4414,31 @@ {"recordType":"path","path":"plugins.entries.voice-call.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/voice-call","hasChildren":false} {"recordType":"path","path":"plugins.entries.voice-call.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.voice-call.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.volcengine","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/volcengine-provider","help":"OpenClaw Volcengine provider plugin (plugin: volcengine)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.volcengine.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/volcengine-provider Config","help":"Plugin-defined config payload for volcengine.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.volcengine.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/volcengine-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.volcengine.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.volcengine.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.whatsapp","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/whatsapp","help":"OpenClaw WhatsApp channel plugin (plugin: whatsapp)","hasChildren":true} {"recordType":"path","path":"plugins.entries.whatsapp.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/whatsapp Config","help":"Plugin-defined config payload for whatsapp.","hasChildren":false} {"recordType":"path","path":"plugins.entries.whatsapp.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/whatsapp","hasChildren":false} {"recordType":"path","path":"plugins.entries.whatsapp.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} {"recordType":"path","path":"plugins.entries.whatsapp.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.xai","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xai-plugin","help":"OpenClaw xAI plugin (plugin: xai)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.xai.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xai-plugin Config","help":"Plugin-defined config payload for xai.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.xai.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/xai-plugin","hasChildren":false} +{"recordType":"path","path":"plugins.entries.xai.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.xai.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.xiaomi","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xiaomi-provider","help":"OpenClaw Xiaomi provider plugin (plugin: xiaomi)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.xiaomi.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/xiaomi-provider Config","help":"Plugin-defined config payload for xiaomi.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.xiaomi.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/xiaomi-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.xiaomi.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.xiaomi.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.zai","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/zai-provider","help":"OpenClaw Z.AI provider plugin (plugin: zai)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.zai.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/zai-provider Config","help":"Plugin-defined config payload for zai.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.zai.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/zai-provider","hasChildren":false} +{"recordType":"path","path":"plugins.entries.zai.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.zai.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} {"recordType":"path","path":"plugins.entries.zalo","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/zalo","help":"OpenClaw Zalo channel plugin (plugin: zalo)","hasChildren":true} {"recordType":"path","path":"plugins.entries.zalo.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/zalo Config","help":"Plugin-defined config payload for zalo.","hasChildren":false} {"recordType":"path","path":"plugins.entries.zalo.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/zalo","hasChildren":false} From 0218045818ec951bf58b9052707a783aee8a0c6e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:02:25 -0700 Subject: [PATCH 177/943] test: silence vitest warning noise --- src/cli/program.test-mocks.ts | 164 +++++++++++++++++------------ src/infra/warning-filter.test.ts | 9 ++ src/plugins/loader.test.ts | 14 +-- ui/src/i18n/lib/translate.ts | 9 +- ui/src/i18n/test/translate.test.ts | 16 +++ ui/src/local-storage.ts | 25 +++++ ui/src/ui/app-render.ts | 5 +- ui/src/ui/chat/deleted-messages.ts | 6 +- ui/src/ui/chat/grouped-render.ts | 5 +- ui/src/ui/chat/pinned-messages.ts | 6 +- ui/src/ui/controllers/usage.ts | 10 +- ui/src/ui/device-auth.ts | 5 +- ui/src/ui/device-identity.ts | 8 +- ui/src/ui/storage.ts | 9 +- ui/src/ui/views/chat.test.ts | 5 +- 15 files changed, 190 insertions(+), 106 deletions(-) create mode 100644 ui/src/local-storage.ts diff --git a/src/cli/program.test-mocks.ts b/src/cli/program.test-mocks.ts index ab0d6b497bf..cf71122749f 100644 --- a/src/cli/program.test-mocks.ts +++ b/src/cli/program.test-mocks.ts @@ -1,78 +1,104 @@ -import { Mock, vi } from "vitest"; +import { vi, type Mock } from "vitest"; -export const messageCommand: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const statusCommand: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const configureCommand: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const configureCommandWithSections: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const setupCommand: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const onboardCommand: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const callGateway: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const runChannelLogin: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const runChannelLogout: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const runTui: Mock<(...args: unknown[]) => unknown> = vi.fn(); +type AnyMock = Mock<(...args: unknown[]) => unknown>; -export const loadAndMaybeMigrateDoctorConfig: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const ensureConfigReady: Mock<(...args: unknown[]) => unknown> = vi.fn(); -export const ensurePluginRegistryLoaded: Mock<(...args: unknown[]) => unknown> = vi.fn(); +const programMocks = vi.hoisted(() => ({ + messageCommand: vi.fn(), + statusCommand: vi.fn(), + configureCommand: vi.fn(), + configureCommandWithSections: vi.fn(), + setupCommand: vi.fn(), + onboardCommand: vi.fn(), + callGateway: vi.fn(), + runChannelLogin: vi.fn(), + runChannelLogout: vi.fn(), + runTui: vi.fn(), + loadAndMaybeMigrateDoctorConfig: vi.fn(), + ensureConfigReady: vi.fn(), + ensurePluginRegistryLoaded: vi.fn(), + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("exit"); + }), + }, +})); -export const runtime: { +export const messageCommand = programMocks.messageCommand as AnyMock; +export const statusCommand = programMocks.statusCommand as AnyMock; +export const configureCommand = programMocks.configureCommand as AnyMock; +export const configureCommandWithSections = programMocks.configureCommandWithSections as AnyMock; +export const setupCommand = programMocks.setupCommand as AnyMock; +export const onboardCommand = programMocks.onboardCommand as AnyMock; +export const callGateway = programMocks.callGateway as AnyMock; +export const runChannelLogin = programMocks.runChannelLogin as AnyMock; +export const runChannelLogout = programMocks.runChannelLogout as AnyMock; +export const runTui = programMocks.runTui as AnyMock; +export const loadAndMaybeMigrateDoctorConfig = + programMocks.loadAndMaybeMigrateDoctorConfig as AnyMock; +export const ensureConfigReady = programMocks.ensureConfigReady as AnyMock; +export const ensurePluginRegistryLoaded = programMocks.ensurePluginRegistryLoaded as AnyMock; + +export const runtime = programMocks.runtime as { log: Mock<(...args: unknown[]) => void>; error: Mock<(...args: unknown[]) => void>; exit: Mock<(...args: unknown[]) => never>; -} = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(() => { - throw new Error("exit"); - }), }; -export function installBaseProgramMocks() { - vi.mock("../commands/message.js", () => ({ messageCommand })); - vi.mock("../commands/status.js", () => ({ statusCommand })); - vi.mock("../commands/configure.js", () => ({ - CONFIGURE_WIZARD_SECTIONS: [ - "workspace", - "model", - "web", - "gateway", - "daemon", - "channels", - "skills", - "health", - ], - configureCommand, - configureCommandWithSections, - configureCommandFromSectionsArg: (sections: unknown, runtime: unknown) => { - const resolved = Array.isArray(sections) ? sections : []; - if (resolved.length > 0) { - return configureCommandWithSections(resolved, runtime); - } - return configureCommand({}, runtime); - }, - })); - vi.mock("../commands/setup.js", () => ({ setupCommand })); - vi.mock("../commands/onboard.js", () => ({ onboardCommand })); - vi.mock("../runtime.js", () => ({ defaultRuntime: runtime })); - vi.mock("./channel-auth.js", () => ({ runChannelLogin, runChannelLogout })); - vi.mock("../tui/tui.js", () => ({ runTui })); - vi.mock("../gateway/call.js", () => ({ - callGateway, - randomIdempotencyKey: () => "idem-test", - buildGatewayConnectionDetails: () => ({ - url: "ws://127.0.0.1:1234", - urlSource: "test", - message: "Gateway target: ws://127.0.0.1:1234", - }), - })); - vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({}) })); -} +// Keep these mocks at top level so Vitest does not warn about hoisted nested mocks. +vi.mock("../commands/message.js", () => ({ messageCommand: programMocks.messageCommand })); +vi.mock("../commands/status.js", () => ({ statusCommand: programMocks.statusCommand })); +vi.mock("../commands/configure.js", () => ({ + CONFIGURE_WIZARD_SECTIONS: [ + "workspace", + "model", + "web", + "gateway", + "daemon", + "channels", + "skills", + "health", + ], + configureCommand: programMocks.configureCommand, + configureCommandWithSections: programMocks.configureCommandWithSections, + configureCommandFromSectionsArg: (sections: unknown, runtime: unknown) => { + const resolved = Array.isArray(sections) ? sections : []; + if (resolved.length > 0) { + return programMocks.configureCommandWithSections(resolved, runtime); + } + return programMocks.configureCommand({}, runtime); + }, +})); +vi.mock("../commands/setup.js", () => ({ setupCommand: programMocks.setupCommand })); +vi.mock("../commands/onboard.js", () => ({ onboardCommand: programMocks.onboardCommand })); +vi.mock("../runtime.js", () => ({ defaultRuntime: programMocks.runtime })); +vi.mock("./channel-auth.js", () => ({ + runChannelLogin: programMocks.runChannelLogin, + runChannelLogout: programMocks.runChannelLogout, +})); +vi.mock("../tui/tui.js", () => ({ runTui: programMocks.runTui })); +vi.mock("../gateway/call.js", () => ({ + callGateway: programMocks.callGateway, + randomIdempotencyKey: () => "idem-test", + buildGatewayConnectionDetails: () => ({ + url: "ws://127.0.0.1:1234", + urlSource: "test", + message: "Gateway target: ws://127.0.0.1:1234", + }), +})); +vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({}) })); +vi.mock("./plugin-registry.js", () => ({ + ensurePluginRegistryLoaded: programMocks.ensurePluginRegistryLoaded, +})); +vi.mock("../commands/doctor-config-flow.js", () => ({ + loadAndMaybeMigrateDoctorConfig: programMocks.loadAndMaybeMigrateDoctorConfig, +})); +vi.mock("./program/config-guard.js", () => ({ + ensureConfigReady: programMocks.ensureConfigReady, +})); +vi.mock("./preaction.js", () => ({ registerPreActionHooks: () => {} })); -export function installSmokeProgramMocks() { - vi.mock("./plugin-registry.js", () => ({ ensurePluginRegistryLoaded })); - vi.mock("../commands/doctor-config-flow.js", () => ({ - loadAndMaybeMigrateDoctorConfig, - })); - vi.mock("./program/config-guard.js", () => ({ ensureConfigReady })); - vi.mock("./preaction.js", () => ({ registerPreActionHooks: () => {} })); -} +export function installBaseProgramMocks() {} + +export function installSmokeProgramMocks() {} diff --git a/src/infra/warning-filter.test.ts b/src/infra/warning-filter.test.ts index 7ce9854aa9a..da4b9dad163 100644 --- a/src/infra/warning-filter.test.ts +++ b/src/infra/warning-filter.test.ts @@ -74,6 +74,7 @@ describe("warning filter", () => { it("installs once and suppresses known warnings at emit time", async () => { const seenWarnings: Array<{ code?: string; name: string; message: string }> = []; + const stderrWrites: string[] = []; const onWarning = (warning: Error & { code?: string }) => { seenWarnings.push({ code: warning.code, @@ -81,6 +82,12 @@ describe("warning filter", () => { message: warning.message, }); }; + const stderrWriteSpy = vi.spyOn(process.stderr, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + stderrWrites.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + }) as typeof process.stderr.write); process.on("warning", onWarning); try { @@ -135,7 +142,9 @@ describe("warning filter", () => { warning.code === "DEP0040" && warning.message === "The punycode module is deprecated.", ), ).toBeDefined(); + expect(stderrWrites.join("")).toContain("Visible warning"); } finally { + stderrWriteSpy.mockRestore(); process.off("warning", onWarning); } }); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 45710ef08bf..d442685a3ff 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -7,13 +7,13 @@ import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { withEnv } from "../test-utils/env.js"; async function importFreshPluginTestModules() { vi.resetModules(); - vi.unmock("node:fs"); - vi.unmock("node:fs/promises"); - vi.unmock("node:module"); - vi.unmock("./hook-runner-global.js"); - vi.unmock("./hooks.js"); - vi.unmock("./loader.js"); - vi.unmock("jiti"); + vi.doUnmock("node:fs"); + vi.doUnmock("node:fs/promises"); + vi.doUnmock("node:module"); + vi.doUnmock("./hook-runner-global.js"); + vi.doUnmock("./hooks.js"); + vi.doUnmock("./loader.js"); + vi.doUnmock("jiti"); const [loader, hookRunnerGlobal, hooks, runtime, registry] = await Promise.all([ import("./loader.js"), import("./hook-runner-global.js"), diff --git a/ui/src/i18n/lib/translate.ts b/ui/src/i18n/lib/translate.ts index fc18f36c8e5..11759bc6d8d 100644 --- a/ui/src/i18n/lib/translate.ts +++ b/ui/src/i18n/lib/translate.ts @@ -1,3 +1,4 @@ +import { getSafeLocalStorage } from "../../local-storage.ts"; import { en } from "../locales/en.ts"; import { DEFAULT_LOCALE, @@ -22,8 +23,8 @@ class I18nManager { } private readStoredLocale(): string | null { - const storage = globalThis.localStorage; - if (!storage || typeof storage.getItem !== "function") { + const storage = getSafeLocalStorage(); + if (!storage) { return null; } try { @@ -34,8 +35,8 @@ class I18nManager { } private persistLocale(locale: Locale) { - const storage = globalThis.localStorage; - if (!storage || typeof storage.setItem !== "function") { + const storage = getSafeLocalStorage(); + if (!storage) { return; } try { diff --git a/ui/src/i18n/test/translate.test.ts b/ui/src/i18n/test/translate.test.ts index d373d3a47c9..14344b9079b 100644 --- a/ui/src/i18n/test/translate.test.ts +++ b/ui/src/i18n/test/translate.test.ts @@ -92,6 +92,22 @@ describe("i18n", () => { expect(fresh.t("common.health")).toBe("健康状况"); }); + it("skips node localStorage accessors that warn without a storage file", async () => { + vi.resetModules(); + vi.unstubAllGlobals(); + vi.stubGlobal("navigator", { language: "en-US" } as Navigator); + const warningSpy = vi.spyOn(process, "emitWarning"); + + const fresh = await import("../lib/translate.ts"); + + expect(fresh.i18n.getLocale()).toBe("en"); + expect(warningSpy).not.toHaveBeenCalledWith( + "`--localstorage-file` was provided without a valid path", + expect.anything(), + expect.anything(), + ); + }); + it("keeps the version label available in shipped locales", () => { expect((pt_BR.common as { version?: string }).version).toBeTruthy(); expect((zh_CN.common as { version?: string }).version).toBeTruthy(); diff --git a/ui/src/local-storage.ts b/ui/src/local-storage.ts new file mode 100644 index 00000000000..a1e80d9d32a --- /dev/null +++ b/ui/src/local-storage.ts @@ -0,0 +1,25 @@ +function isStorage(value: unknown): value is Storage { + return ( + Boolean(value) && + typeof (value as Storage).getItem === "function" && + typeof (value as Storage).setItem === "function" + ); +} + +export function getSafeLocalStorage(): Storage | null { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + + if (process.env.VITEST) { + return descriptor && !descriptor.get && isStorage(descriptor.value) ? descriptor.value : null; + } + + if (typeof window !== "undefined" && typeof document !== "undefined") { + try { + return isStorage(window.localStorage) ? window.localStorage : null; + } catch { + return null; + } + } + + return descriptor && !descriptor.get && isStorage(descriptor.value) ? descriptor.value : null; +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 328f2cb6e33..11bcacae1ee 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -4,6 +4,7 @@ import { parseAgentSessionKey, } from "../../../src/routing/session-key.js"; import { t } from "../i18n/index.ts"; +import { getSafeLocalStorage } from "../local-storage.ts"; import { refreshChatAvatar } from "./app-chat.ts"; import { renderUsageTab } from "./app-render-usage-tab.ts"; import { @@ -181,7 +182,7 @@ type DismissedUpdateBanner = { function loadDismissedUpdateBanner(): DismissedUpdateBanner | null { try { - const raw = localStorage.getItem(UPDATE_BANNER_DISMISS_KEY); + const raw = getSafeLocalStorage()?.getItem(UPDATE_BANNER_DISMISS_KEY); if (!raw) { return null; } @@ -225,7 +226,7 @@ function dismissUpdateBanner(updateAvailable: unknown) { dismissedAtMs: Date.now(), }; try { - localStorage.setItem(UPDATE_BANNER_DISMISS_KEY, JSON.stringify(payload)); + getSafeLocalStorage()?.setItem(UPDATE_BANNER_DISMISS_KEY, JSON.stringify(payload)); } catch { // ignore } diff --git a/ui/src/ui/chat/deleted-messages.ts b/ui/src/ui/chat/deleted-messages.ts index 21094bb9e83..316b659baa8 100644 --- a/ui/src/ui/chat/deleted-messages.ts +++ b/ui/src/ui/chat/deleted-messages.ts @@ -1,3 +1,5 @@ +import { getSafeLocalStorage } from "../../local-storage.ts"; + const PREFIX = "openclaw:deleted:"; export class DeletedMessages { @@ -30,7 +32,7 @@ export class DeletedMessages { private load(): void { try { - const raw = localStorage.getItem(this.key); + const raw = getSafeLocalStorage()?.getItem(this.key); if (!raw) { return; } @@ -45,7 +47,7 @@ export class DeletedMessages { private save(): void { try { - localStorage.setItem(this.key, JSON.stringify([...this._keys])); + getSafeLocalStorage()?.setItem(this.key, JSON.stringify([...this._keys])); } catch { // ignore } diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 5b7549c8d64..7dcc0b62e19 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -1,5 +1,6 @@ import { html, nothing } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { getSafeLocalStorage } from "../../local-storage.ts"; import type { AssistantIdentity } from "../assistant-identity.ts"; import { icons } from "../icons.ts"; import { toSanitizedMarkdownHtml } from "../markdown.ts"; @@ -322,7 +323,7 @@ type DeleteConfirmSide = "left" | "right"; function shouldSkipDeleteConfirm(): boolean { try { - return localStorage.getItem(SKIP_DELETE_CONFIRM_KEY) === "1"; + return getSafeLocalStorage()?.getItem(SKIP_DELETE_CONFIRM_KEY) === "1"; } catch { return false; } @@ -370,7 +371,7 @@ function renderDeleteButton(onDelete: () => void, side: DeleteConfirmSide) { yes.addEventListener("click", () => { if (check.checked) { try { - localStorage.setItem(SKIP_DELETE_CONFIRM_KEY, "1"); + getSafeLocalStorage()?.setItem(SKIP_DELETE_CONFIRM_KEY, "1"); } catch {} } popover.remove(); diff --git a/ui/src/ui/chat/pinned-messages.ts b/ui/src/ui/chat/pinned-messages.ts index a3e77a9483b..3bd7b9d6603 100644 --- a/ui/src/ui/chat/pinned-messages.ts +++ b/ui/src/ui/chat/pinned-messages.ts @@ -1,3 +1,5 @@ +import { getSafeLocalStorage } from "../../local-storage.ts"; + const PREFIX = "openclaw:pinned:"; export class PinnedMessages { @@ -42,7 +44,7 @@ export class PinnedMessages { private load(): void { try { - const raw = localStorage.getItem(this.key); + const raw = getSafeLocalStorage()?.getItem(this.key); if (!raw) { return; } @@ -57,7 +59,7 @@ export class PinnedMessages { private save(): void { try { - localStorage.setItem(this.key, JSON.stringify([...this._indices])); + getSafeLocalStorage()?.setItem(this.key, JSON.stringify([...this._indices])); } catch { // ignore } diff --git a/ui/src/ui/controllers/usage.ts b/ui/src/ui/controllers/usage.ts index 0fe257ae8e7..5862bd82e72 100644 --- a/ui/src/ui/controllers/usage.ts +++ b/ui/src/ui/controllers/usage.ts @@ -1,3 +1,4 @@ +import { getSafeLocalStorage } from "../../local-storage.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; import type { SessionsUsageResult, CostUsageSummary, SessionUsageTimeSeries } from "../types.ts"; import type { SessionLogEntry } from "../views/usage.ts"; @@ -39,14 +40,7 @@ const LEGACY_USAGE_DATE_PARAMS_INVALID_RE = /invalid sessions\.usage params/i; let legacyUsageDateParamsCache: Set | null = null; function getLocalStorage(): Storage | null { - // Support browser runtime and node tests (when localStorage is stubbed globally). - if (typeof window !== "undefined" && window.localStorage) { - return window.localStorage; - } - if (typeof localStorage !== "undefined") { - return localStorage; - } - return null; + return getSafeLocalStorage(); } function loadLegacyUsageDateParamsCache(): Set { diff --git a/ui/src/ui/device-auth.ts b/ui/src/ui/device-auth.ts index 1adcf7deda9..1238a859f1c 100644 --- a/ui/src/ui/device-auth.ts +++ b/ui/src/ui/device-auth.ts @@ -5,12 +5,13 @@ import { storeDeviceAuthTokenInStore, } from "../../../src/shared/device-auth-store.js"; import type { DeviceAuthStore } from "../../../src/shared/device-auth.js"; +import { getSafeLocalStorage } from "../local-storage.ts"; const STORAGE_KEY = "openclaw.device.auth.v1"; function readStore(): DeviceAuthStore | null { try { - const raw = window.localStorage.getItem(STORAGE_KEY); + const raw = getSafeLocalStorage()?.getItem(STORAGE_KEY); if (!raw) { return null; } @@ -32,7 +33,7 @@ function readStore(): DeviceAuthStore | null { function writeStore(store: DeviceAuthStore) { try { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); + getSafeLocalStorage()?.setItem(STORAGE_KEY, JSON.stringify(store)); } catch { // best-effort } diff --git a/ui/src/ui/device-identity.ts b/ui/src/ui/device-identity.ts index 947b8185038..ff20c68649e 100644 --- a/ui/src/ui/device-identity.ts +++ b/ui/src/ui/device-identity.ts @@ -1,4 +1,5 @@ import { getPublicKeyAsync, signAsync, utils } from "@noble/ed25519"; +import { getSafeLocalStorage } from "../local-storage.ts"; type StoredIdentity = { version: 1; @@ -58,8 +59,9 @@ async function generateIdentity(): Promise { } export async function loadOrCreateDeviceIdentity(): Promise { + const storage = getSafeLocalStorage(); try { - const raw = localStorage.getItem(STORAGE_KEY); + const raw = storage?.getItem(STORAGE_KEY); if (raw) { const parsed = JSON.parse(raw) as StoredIdentity; if ( @@ -74,7 +76,7 @@ export async function loadOrCreateDeviceIdentity(): Promise { ...parsed, deviceId: derivedId, }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + storage?.setItem(STORAGE_KEY, JSON.stringify(updated)); return { deviceId: derivedId, publicKey: parsed.publicKey, @@ -100,7 +102,7 @@ export async function loadOrCreateDeviceIdentity(): Promise { privateKey: identity.privateKey, createdAtMs: Date.now(), }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)); + storage?.setItem(STORAGE_KEY, JSON.stringify(stored)); return identity; } diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 450c5124592..0b23b3436a4 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -16,6 +16,7 @@ type PersistedUiSettings = Omit = {}; try { - const raw = localStorage.getItem(KEY); + const raw = storage?.getItem(KEY); if (raw) { const parsed = JSON.parse(raw) as PersistedUiSettings; if (parsed.sessionsByGateway && typeof parsed.sessionsByGateway === "object") { @@ -291,5 +294,5 @@ function persistSettings(next: UiSettings) { sessionsByGateway, ...(next.locale ? { locale: next.locale } : {}), }; - localStorage.setItem(KEY, JSON.stringify(persisted)); + storage?.setItem(KEY, JSON.stringify(persisted)); } diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 860727c1927..ab55db6935f 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -2,6 +2,7 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; +import { getSafeLocalStorage } from "../../local-storage.ts"; import { renderChatSessionSelect } from "../app-render.helpers.ts"; import type { AppViewState } from "../app-view-state.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; @@ -482,7 +483,7 @@ describe("chat view", () => { it("opens delete confirm on the left for user messages", () => { try { - localStorage.removeItem("openclaw:skipDeleteConfirm"); + getSafeLocalStorage()?.removeItem("openclaw:skipDeleteConfirm"); } catch { /* noop */ } @@ -515,7 +516,7 @@ describe("chat view", () => { it("opens delete confirm on the right for assistant messages", () => { try { - localStorage.removeItem("openclaw:skipDeleteConfirm"); + getSafeLocalStorage()?.removeItem("openclaw:skipDeleteConfirm"); } catch { /* noop */ } From 350b42d3424e216d384744ac7fdd855d128fce97 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:01:31 -0700 Subject: [PATCH 178/943] Status: lazy-load text scan helpers --- src/commands/status.scan.runtime.ts | 2 ++ src/commands/status.scan.test.ts | 5 +++++ src/commands/status.scan.ts | 10 ++++++++-- 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 src/commands/status.scan.runtime.ts diff --git a/src/commands/status.scan.runtime.ts b/src/commands/status.scan.runtime.ts new file mode 100644 index 00000000000..372b31f4803 --- /dev/null +++ b/src/commands/status.scan.runtime.ts @@ -0,0 +1,2 @@ +export { collectChannelStatusIssues } from "../infra/channels-status-issues.js"; +export { buildChannelsTable } from "./status-all/channels.js"; diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 122e10076bf..7dccbefb621 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -30,6 +30,11 @@ vi.mock("./status-all/channels.js", () => ({ buildChannelsTable: mocks.buildChannelsTable, })); +vi.mock("./status.scan.runtime.js", () => ({ + buildChannelsTable: mocks.buildChannelsTable, + collectChannelStatusIssues: vi.fn(() => []), +})); + vi.mock("./status.update.js", () => ({ getUpdateCheckResult: mocks.getUpdateCheckResult, })); diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 7f1380964d5..64a17e2b371 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -7,14 +7,12 @@ import { readBestEffortConfig } from "../config/config.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; import { probeGateway } from "../gateway/probe.js"; -import { collectChannelStatusIssues } from "../infra/channels-status-issues.js"; import { resolveOsSummary } from "../infra/os-summary.js"; import { getTailnetHostname } from "../infra/tailscale.js"; import { getMemorySearchManager } from "../memory/index.js"; import type { MemoryProviderStatus } from "../memory/types.js"; import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; -import { buildChannelsTable } from "./status-all/channels.js"; import { getAgentLocalStatuses } from "./status.agent-local.js"; import { pickGatewaySelfPresence, @@ -48,12 +46,18 @@ type GatewayProbeSnapshot = { }; let pluginRegistryModulePromise: Promise | undefined; +let statusScanRuntimeModulePromise: Promise | undefined; function loadPluginRegistryModule() { pluginRegistryModulePromise ??= import("../cli/plugin-registry.js"); return pluginRegistryModulePromise; } +function loadStatusScanRuntimeModule() { + statusScanRuntimeModulePromise ??= import("./status.scan.runtime.js"); + return statusScanRuntimeModulePromise; +} + function deferResult(promise: Promise): Promise> { return promise.then( (value) => ({ ok: true, value }), @@ -360,6 +364,8 @@ export async function scanStatus( progress.setLabel("Querying channel status…"); const channelsStatus = await resolveChannelsStatus({ cfg, gatewayReachable, opts }); + const { collectChannelStatusIssues, buildChannelsTable } = + await loadStatusScanRuntimeModule(); const channelIssues = channelsStatus ? collectChannelStatusIssues(channelsStatus) : []; progress.tick(); From 53ccc78c636322f2b17649e83e67862d913dda9c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:06:55 -0700 Subject: [PATCH 179/943] refactor: rename setup helper surfaces --- extensions/feishu/src/onboarding.ts | 7 ------- ...ng.status.test.ts => setup-status.test.ts} | 0 ...boarding.test.ts => setup-surface.test.ts} | 0 ...boarding.test.ts => setup-surface.test.ts} | 2 +- ...boarding.test.ts => setup-surface.test.ts} | 0 ...ng.status.test.ts => setup-status.test.ts} | 0 .../plugins/setup-flow-helpers.test.ts | 2 +- src/channels/plugins/setup-flow-helpers.ts | 2 +- src/channels/plugins/setup-flow-types.ts | 4 ++-- src/commands/channels/add.ts | 4 ++-- src/commands/onboard-channels.ts | 20 +++++++++---------- src/plugin-sdk/{onboarding.ts => setup.ts} | 0 12 files changed, 16 insertions(+), 25 deletions(-) delete mode 100644 extensions/feishu/src/onboarding.ts rename extensions/feishu/src/{onboarding.status.test.ts => setup-status.test.ts} (100%) rename extensions/feishu/src/{onboarding.test.ts => setup-surface.test.ts} (100%) rename extensions/irc/src/{onboarding.test.ts => setup-surface.test.ts} (98%) rename extensions/whatsapp/src/{onboarding.test.ts => setup-surface.test.ts} (100%) rename extensions/zalo/src/{onboarding.status.test.ts => setup-status.test.ts} (100%) rename src/plugin-sdk/{onboarding.ts => setup.ts} (100%) diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/onboarding.ts deleted file mode 100644 index ae247b30f76..00000000000 --- a/extensions/feishu/src/onboarding.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import { feishuPlugin } from "./channel.js"; - -export const feishuOnboardingAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ - plugin: feishuPlugin, - wizard: feishuPlugin.setupWizard!, -}); diff --git a/extensions/feishu/src/onboarding.status.test.ts b/extensions/feishu/src/setup-status.test.ts similarity index 100% rename from extensions/feishu/src/onboarding.status.test.ts rename to extensions/feishu/src/setup-status.test.ts diff --git a/extensions/feishu/src/onboarding.test.ts b/extensions/feishu/src/setup-surface.test.ts similarity index 100% rename from extensions/feishu/src/onboarding.test.ts rename to extensions/feishu/src/setup-surface.test.ts diff --git a/extensions/irc/src/onboarding.test.ts b/extensions/irc/src/setup-surface.test.ts similarity index 98% rename from extensions/irc/src/onboarding.test.ts rename to extensions/irc/src/setup-surface.test.ts index 883f15fe1b1..92cca5f0f35 100644 --- a/extensions/irc/src/onboarding.test.ts +++ b/extensions/irc/src/setup-surface.test.ts @@ -33,7 +33,7 @@ const ircConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ }); describe("irc setup wizard", () => { - it("configures host and nick via onboarding prompts", async () => { + it("configures host and nick via setup prompts", async () => { const prompter = createPrompter({ text: vi.fn(async ({ message }: { message: string }) => { if (message === "IRC server host") { diff --git a/extensions/whatsapp/src/onboarding.test.ts b/extensions/whatsapp/src/setup-surface.test.ts similarity index 100% rename from extensions/whatsapp/src/onboarding.test.ts rename to extensions/whatsapp/src/setup-surface.test.ts diff --git a/extensions/zalo/src/onboarding.status.test.ts b/extensions/zalo/src/setup-status.test.ts similarity index 100% rename from extensions/zalo/src/onboarding.status.test.ts rename to extensions/zalo/src/setup-status.test.ts diff --git a/src/channels/plugins/setup-flow-helpers.test.ts b/src/channels/plugins/setup-flow-helpers.test.ts index 3b24600372c..d13ce6a3b6b 100644 --- a/src/channels/plugins/setup-flow-helpers.test.ts +++ b/src/channels/plugins/setup-flow-helpers.test.ts @@ -3,7 +3,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; const promptAccountIdSdkMock = vi.hoisted(() => vi.fn(async () => "default")); -vi.mock("../../plugin-sdk/onboarding.js", () => ({ +vi.mock("../../plugin-sdk/setup.js", () => ({ promptAccountId: promptAccountIdSdkMock, })); diff --git a/src/channels/plugins/setup-flow-helpers.ts b/src/channels/plugins/setup-flow-helpers.ts index 87a208a9a21..b0519b8f35d 100644 --- a/src/channels/plugins/setup-flow-helpers.ts +++ b/src/channels/plugins/setup-flow-helpers.ts @@ -5,7 +5,7 @@ import { import type { OpenClawConfig } from "../../config/config.js"; import type { DmPolicy, GroupPolicy } from "../../config/types.js"; import type { SecretInput } from "../../config/types.secrets.js"; -import { promptAccountId as promptAccountIdSdk } from "../../plugin-sdk/onboarding.js"; +import { promptAccountId as promptAccountIdSdk } from "../../plugin-sdk/setup.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; import type { PromptAccountId, PromptAccountIdParams } from "./setup-flow-types.js"; diff --git a/src/channels/plugins/setup-flow-types.ts b/src/channels/plugins/setup-flow-types.ts index a3887cc7ef2..53766d72af6 100644 --- a/src/channels/plugins/setup-flow-types.ts +++ b/src/channels/plugins/setup-flow-types.ts @@ -4,7 +4,7 @@ import type { RuntimeEnv } from "../../runtime.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; import type { ChannelId, ChannelPlugin } from "./types.js"; -export type ChannelOnboardingSetupPlugin = Pick< +export type ChannelSetupPlugin = Pick< ChannelPlugin, "id" | "meta" | "capabilities" | "config" | "setup" | "setupWizard" >; @@ -15,7 +15,7 @@ export type SetupChannelsOptions = { onSelection?: (selection: ChannelId[]) => void; accountIds?: Partial>; onAccountId?: (channel: ChannelId, accountId: string) => void; - onResolvedPlugin?: (channel: ChannelId, plugin: ChannelOnboardingSetupPlugin) => void; + onResolvedPlugin?: (channel: ChannelId, plugin: ChannelSetupPlugin) => void; promptAccountIds?: boolean; whatsappAccountId?: string; promptWhatsAppAccountId?: boolean; diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index d4175cf100b..b4f8205ae3a 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -2,7 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/ag import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js"; import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; -import type { ChannelOnboardingSetupPlugin } from "../../channels/plugins/setup-flow-types.js"; +import type { ChannelSetupPlugin } from "../../channels/plugins/setup-flow-types.js"; import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js"; import type { ChannelId, ChannelPlugin, ChannelSetupInput } from "../../channels/plugins/types.js"; import { writeConfigFile, type OpenClawConfig } from "../../config/config.js"; @@ -57,7 +57,7 @@ export async function channelsAddCommand( const prompter = createClackPrompter(); let selection: ChannelChoice[] = []; const accountIds: Partial> = {}; - const resolvedPlugins = new Map(); + const resolvedPlugins = new Map(); await prompter.intro("Channel setup"); let nextConfig = await setupChannels(cfg, runtime, prompter, { allowDisable: false, diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 67c78e7a72c..564e056b053 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -1,7 +1,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; -import type { ChannelOnboardingSetupPlugin } from "../channels/plugins/setup-flow-types.js"; +import type { ChannelSetupPlugin } from "../channels/plugins/setup-flow-types.js"; import { getChannelSetupPlugin, listChannelSetupPlugins, @@ -91,7 +91,7 @@ async function promptRemovalAccountId(params: { prompter: WizardPrompter; label: string; channel: ChannelChoice; - plugin?: ChannelOnboardingSetupPlugin; + plugin?: ChannelSetupPlugin; }): Promise { const { cfg, prompter, label, channel } = params; const plugin = params.plugin ?? getChannelSetupPlugin(channel); @@ -118,7 +118,7 @@ async function collectChannelStatus(params: { cfg: OpenClawConfig; options?: SetupChannelsOptions; accountOverrides: Partial>; - installedPlugins?: ChannelOnboardingSetupPlugin[]; + installedPlugins?: ChannelSetupPlugin[]; resolveAdapter?: (channel: ChannelChoice) => ChannelSetupFlowAdapter | undefined; }): Promise { const installedPlugins = params.installedPlugins ?? listChannelSetupPlugins(); @@ -347,19 +347,17 @@ export async function setupChannels( const accountOverrides: Partial> = { ...options?.accountIds, }; - const scopedPluginsById = new Map(); + const scopedPluginsById = new Map(); const resolveWorkspaceDir = () => resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next)); - const rememberScopedPlugin = (plugin: ChannelOnboardingSetupPlugin) => { + const rememberScopedPlugin = (plugin: ChannelSetupPlugin) => { const channel = plugin.id; scopedPluginsById.set(channel, plugin); options?.onResolvedPlugin?.(channel, plugin); }; - const getVisibleChannelPlugin = ( - channel: ChannelChoice, - ): ChannelOnboardingSetupPlugin | undefined => + const getVisibleChannelPlugin = (channel: ChannelChoice): ChannelSetupPlugin | undefined => scopedPluginsById.get(channel) ?? getChannelSetupPlugin(channel); - const listVisibleInstalledPlugins = (): ChannelOnboardingSetupPlugin[] => { - const merged = new Map(); + const listVisibleInstalledPlugins = (): ChannelSetupPlugin[] => { + const merged = new Map(); for (const plugin of listChannelSetupPlugins()) { merged.set(plugin.id, plugin); } @@ -371,7 +369,7 @@ export async function setupChannels( const loadScopedChannelPlugin = async ( channel: ChannelChoice, pluginId?: string, - ): Promise => { + ): Promise => { const existing = getVisibleChannelPlugin(channel); if (existing) { return existing; diff --git a/src/plugin-sdk/onboarding.ts b/src/plugin-sdk/setup.ts similarity index 100% rename from src/plugin-sdk/onboarding.ts rename to src/plugin-sdk/setup.ts From 0f43dc46808bca5cd9ff355469030ad322c6044e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:07:05 -0700 Subject: [PATCH 180/943] test: fix fetch mock typing --- extensions/msteams/src/graph-upload.test.ts | 7 +++--- src/agents/model-auth.test.ts | 23 +++++++++++-------- src/infra/fetch.test.ts | 2 +- src/infra/provider-usage.fetch.shared.test.ts | 5 ++-- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/extensions/msteams/src/graph-upload.test.ts b/extensions/msteams/src/graph-upload.test.ts index 484075984dd..b79086f54ca 100644 --- a/extensions/msteams/src/graph-upload.test.ts +++ b/extensions/msteams/src/graph-upload.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { withFetchPreconnect } from "../../../src/test-utils/fetch-mock.js"; import { uploadToOneDrive, uploadToSharePoint } from "./graph-upload.js"; describe("graph upload helpers", () => { @@ -22,7 +23,7 @@ describe("graph upload helpers", () => { buffer: Buffer.from("hello"), filename: "a.txt", tokenProvider, - fetchFn: fetchFn as typeof fetch, + fetchFn: withFetchPreconnect(fetchFn), }); expect(fetchFn).toHaveBeenCalledWith( @@ -59,7 +60,7 @@ describe("graph upload helpers", () => { filename: "b.txt", siteId: "site-123", tokenProvider, - fetchFn: fetchFn as typeof fetch, + fetchFn: withFetchPreconnect(fetchFn), }); expect(fetchFn).toHaveBeenCalledWith( @@ -94,7 +95,7 @@ describe("graph upload helpers", () => { filename: "bad.txt", siteId: "site-123", tokenProvider, - fetchFn: fetchFn as typeof fetch, + fetchFn: withFetchPreconnect(fetchFn), }), ).rejects.toThrow("SharePoint upload response missing required fields"); }); diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index de8f0f1b752..31fdee5496c 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -1,5 +1,6 @@ import { streamSimpleOpenAICompletions, type Model } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; import type { AuthProfileStore } from "./auth-profiles.js"; import { CUSTOM_LOCAL_AUTH_MARKER, NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; import { @@ -503,16 +504,18 @@ describe("applyLocalNoAuthHeaderOverride", () => { const requestSeen = new Promise((resolve) => { resolveRequest = resolve; }); - globalThis.fetch = vi.fn(async (_input, init) => { - const headers = new Headers(init?.headers); - capturedAuthorization = headers.get("Authorization"); - capturedXTest = headers.get("X-Test"); - resolveRequest?.(); - return new Response(JSON.stringify({ error: { message: "unauthorized" } }), { - status: 401, - headers: { "content-type": "application/json" }, - }); - }) as typeof fetch; + globalThis.fetch = withFetchPreconnect( + vi.fn(async (_input, init) => { + const headers = new Headers(init?.headers); + capturedAuthorization = headers.get("Authorization"); + capturedXTest = headers.get("X-Test"); + resolveRequest?.(); + return new Response(JSON.stringify({ error: { message: "unauthorized" } }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + }), + ); const model = applyLocalNoAuthHeaderOverride( { diff --git a/src/infra/fetch.test.ts b/src/infra/fetch.test.ts index deef81f551f..820325c0e70 100644 --- a/src/infra/fetch.test.ts +++ b/src/infra/fetch.test.ts @@ -293,7 +293,7 @@ describe("wrapFetchWithAbortSignal", () => { }); it("exposes a no-op preconnect when the source fetch has none", () => { - const fetchImpl = vi.fn(async () => ({ ok: true }) as Response) as typeof fetch; + const fetchImpl = withFetchPreconnect(vi.fn(async () => ({ ok: true }) as Response)); const wrapped = wrapFetchWithAbortSignal(fetchImpl) as typeof fetch & { preconnect: (url: string, init?: { credentials?: RequestCredentials }) => unknown; }; diff --git a/src/infra/provider-usage.fetch.shared.test.ts b/src/infra/provider-usage.fetch.shared.test.ts index 692a57705db..b287f1fad04 100644 --- a/src/infra/provider-usage.fetch.shared.test.ts +++ b/src/infra/provider-usage.fetch.shared.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; import { buildUsageErrorSnapshot, buildUsageHttpErrorSnapshot, @@ -36,7 +37,7 @@ describe("provider usage fetch shared helpers", () => { async (_input: URL | RequestInfo, init?: RequestInit) => new Response(JSON.stringify({ aborted: init?.signal?.aborted ?? false }), { status: 200 }), ); - const fetchFn = fetchFnMock as typeof fetch; + const fetchFn = withFetchPreconnect(fetchFnMock); const response = await fetchJson( "https://example.com/usage", @@ -71,7 +72,7 @@ describe("provider usage fetch shared helpers", () => { }); }), ); - const fetchFn = fetchFnMock as typeof fetch; + const fetchFn = withFetchPreconnect(fetchFnMock); const request = fetchJson("https://example.com/usage", {}, 50, fetchFn); const rejection = expect(request).rejects.toThrow("aborted by timeout"); From c4a5fd8465cbee23e2f3b6986c16f448763b34ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:07:18 -0700 Subject: [PATCH 181/943] docs: update channel setup wording --- docs/channels/feishu.md | 4 ++-- docs/channels/matrix.md | 4 ++-- docs/channels/mattermost.md | 2 +- docs/channels/msteams.md | 2 +- docs/channels/nextcloud-talk.md | 4 ++-- docs/channels/nostr.md | 2 +- docs/channels/telegram.md | 2 +- docs/channels/whatsapp.md | 2 +- docs/channels/zalo.md | 6 +++--- docs/channels/zalouser.md | 4 ++-- docs/tools/plugin.md | 2 +- 11 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 2fc16aed5d4..3768906d940 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -30,9 +30,9 @@ openclaw plugins install @openclaw/feishu There are two ways to add the Feishu channel: -### Method 1: onboarding wizard (recommended) +### Method 1: setup wizard (recommended) -If you just installed OpenClaw, run the wizard: +If you just installed OpenClaw, run the setup wizard: ```bash openclaw onboard diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 9bb56d1ddb7..1536a7c08ac 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -31,7 +31,7 @@ Local checkout (when running from a git repo): openclaw plugins install ./extensions/matrix ``` -If you choose Matrix during configure/onboarding and a git checkout is detected, +If you choose Matrix during setup and a git checkout is detected, OpenClaw will offer the local install path automatically. Details: [Plugins](/tools/plugin) @@ -72,7 +72,7 @@ Details: [Plugins](/tools/plugin) - If both are set, config takes precedence. - With access token: user ID is fetched automatically via `/whoami`. - When set, `channels.matrix.userId` should be the full Matrix ID (example: `@bot:example.org`). -5. Restart the gateway (or finish onboarding). +5. Restart the gateway (or finish setup). 6. Start a DM with the bot or invite it to a room from any Matrix client (Element, Beeper, etc.; see [https://matrix.org/ecosystem/clients/](https://matrix.org/ecosystem/clients/)). Beeper requires E2EE, so set `channels.matrix.encryption: true` and verify the device. diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md index 1e3e3f4bad2..2ceb6c17626 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -28,7 +28,7 @@ Local checkout (when running from a git repo): openclaw plugins install ./extensions/mattermost ``` -If you choose Mattermost during configure/onboarding and a git checkout is detected, +If you choose Mattermost during setup and a git checkout is detected, OpenClaw will offer the local install path automatically. Details: [Plugins](/tools/plugin) diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index a24f20c69df..88cba3ce6aa 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -33,7 +33,7 @@ Local checkout (when running from a git repo): openclaw plugins install ./extensions/msteams ``` -If you choose Teams during configure/onboarding and a git checkout is detected, +If you choose Teams during setup and a git checkout is detected, OpenClaw will offer the local install path automatically. Details: [Plugins](/tools/plugin) diff --git a/docs/channels/nextcloud-talk.md b/docs/channels/nextcloud-talk.md index 7797b1276ff..f8be8d74f0c 100644 --- a/docs/channels/nextcloud-talk.md +++ b/docs/channels/nextcloud-talk.md @@ -25,7 +25,7 @@ Local checkout (when running from a git repo): openclaw plugins install ./extensions/nextcloud-talk ``` -If you choose Nextcloud Talk during configure/onboarding and a git checkout is detected, +If you choose Nextcloud Talk during setup and a git checkout is detected, OpenClaw will offer the local install path automatically. Details: [Plugins](/tools/plugin) @@ -43,7 +43,7 @@ Details: [Plugins](/tools/plugin) 4. Configure OpenClaw: - Config: `channels.nextcloud-talk.baseUrl` + `channels.nextcloud-talk.botSecret` - Or env: `NEXTCLOUD_TALK_BOT_SECRET` (default account only) -5. Restart the gateway (or finish onboarding). +5. Restart the gateway (or finish setup). Minimal config: diff --git a/docs/channels/nostr.md b/docs/channels/nostr.md index 760704b589f..46888da0352 100644 --- a/docs/channels/nostr.md +++ b/docs/channels/nostr.md @@ -16,7 +16,7 @@ Nostr is a decentralized protocol for social networking. This channel enables Op ### Onboarding (recommended) -- The onboarding wizard (`openclaw onboard`) and `openclaw channels add` list optional channel plugins. +- The setup wizard (`openclaw onboard`) and `openclaw channels add` list optional channel plugins. - Selecting Nostr prompts you to install the plugin on demand. Install defaults: diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 37be3bf1111..b5700213830 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -115,7 +115,7 @@ Token resolution order is account-aware. In practice, config values win over env `channels.telegram.allowFrom` accepts numeric Telegram user IDs. `telegram:` / `tg:` prefixes are accepted and normalized. `dmPolicy: "allowlist"` with empty `allowFrom` blocks all DMs and is rejected by config validation. - The onboarding wizard accepts `@username` input and resolves it to numeric IDs. + The setup wizard accepts `@username` input and resolves it to numeric IDs. If you upgraded and your config contains `@username` allowlist entries, run `openclaw doctor --fix` to resolve them (best-effort; requires a Telegram bot token). If you previously relied on pairing-store allowlist files, `openclaw doctor --fix` can recover entries into `channels.telegram.allowFrom` in allowlist flows (for example when `dmPolicy: "allowlist"` has no explicit IDs yet). diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index cad9fe77ee3..850d88ffcac 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -76,7 +76,7 @@ openclaw pairing approve whatsapp -OpenClaw recommends running WhatsApp on a separate number when possible. (The channel metadata and onboarding flow are optimized for that setup, but personal-number setups are also supported.) +OpenClaw recommends running WhatsApp on a separate number when possible. (The channel metadata and setup flow are optimized for that setup, but personal-number setups are also supported.) ## Deployment patterns diff --git a/docs/channels/zalo.md b/docs/channels/zalo.md index cf53b574e42..b327f596f74 100644 --- a/docs/channels/zalo.md +++ b/docs/channels/zalo.md @@ -14,7 +14,7 @@ Status: experimental. DMs are supported. The [Capabilities](#capabilities) secti Zalo ships as a plugin and is not bundled with the core install. - Install via CLI: `openclaw plugins install @openclaw/zalo` -- Or select **Zalo** during onboarding and confirm the install prompt +- Or select **Zalo** during setup and confirm the install prompt - Details: [Plugins](/tools/plugin) ## Quick setup (beginner) @@ -22,11 +22,11 @@ Zalo ships as a plugin and is not bundled with the core install. 1. Install the Zalo plugin: - From a source checkout: `openclaw plugins install ./extensions/zalo` - From npm (if published): `openclaw plugins install @openclaw/zalo` - - Or pick **Zalo** in onboarding and confirm the install prompt + - Or pick **Zalo** in setup and confirm the install prompt 2. Set the token: - Env: `ZALO_BOT_TOKEN=...` - Or config: `channels.zalo.accounts.default.botToken: "..."`. -3. Restart the gateway (or finish onboarding). +3. Restart the gateway (or finish setup). 4. DM access is pairing by default; approve the pairing code on first contact. Minimal config: diff --git a/docs/channels/zalouser.md b/docs/channels/zalouser.md index 58bd2a43923..4847430c8ac 100644 --- a/docs/channels/zalouser.md +++ b/docs/channels/zalouser.md @@ -41,7 +41,7 @@ No external `zca`/`openzca` CLI binary is required. } ``` -4. Restart the Gateway (or finish onboarding). +4. Restart the Gateway (or finish setup). 5. DM access defaults to pairing; approve the pairing code on first contact. ## What it is @@ -74,7 +74,7 @@ openclaw directory groups list --channel zalouser --query "work" `channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`). -`channels.zalouser.allowFrom` accepts user IDs or names. During onboarding, names are resolved to IDs using the plugin's in-process contact lookup. +`channels.zalouser.allowFrom` accepts user IDs or names. During setup, names are resolved to IDs using the plugin's in-process contact lookup. Approve via: diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 62350fb9dd4..8be0743c57c 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -800,7 +800,7 @@ trees "pure JS/TS" and avoid packages that require `postinstall` builds. Optional: `openclaw.setupEntry` can point at a lightweight setup-only module. When OpenClaw needs setup surfaces for a disabled channel plugin, or when a channel plugin is enabled but still unconfigured, it loads `setupEntry` -instead of the full plugin entry. This keeps startup and onboarding lighter +instead of the full plugin entry. This keeps startup and setup lighter when your main plugin entry also wires tools, hooks, or other runtime-only code. From 093e51f2b35b90d6ed6b8ea808835ab30dac98e6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:08:41 -0700 Subject: [PATCH 182/943] Security: lazy-load channel audit provider helpers --- src/security/audit-channel.runtime.ts | 9 ++++ src/security/audit-channel.ts | 77 ++++++++++++++++----------- 2 files changed, 56 insertions(+), 30 deletions(-) create mode 100644 src/security/audit-channel.runtime.ts diff --git a/src/security/audit-channel.runtime.ts b/src/security/audit-channel.runtime.ts new file mode 100644 index 00000000000..147f686862a --- /dev/null +++ b/src/security/audit-channel.runtime.ts @@ -0,0 +1,9 @@ +export { + isNumericTelegramUserId, + normalizeTelegramAllowFromEntry, +} from "../../extensions/telegram/src/allow-from.js"; +export { readChannelAllowFromStore } from "../pairing/pairing-store.js"; +export { + isDiscordMutableAllowEntry, + isZalouserMutableGroupEntry, +} from "./mutable-allowlist-detectors.js"; diff --git a/src/security/audit-channel.ts b/src/security/audit-channel.ts index ce1484f6513..56f3b139f87 100644 --- a/src/security/audit-channel.ts +++ b/src/security/audit-channel.ts @@ -1,7 +1,3 @@ -import { - isNumericTelegramUserId, - normalizeTelegramAllowFromEntry, -} from "../../extensions/telegram/src/allow-from.js"; import { hasConfiguredUnavailableCredentialStatus, hasResolvedCredentialValue, @@ -15,14 +11,18 @@ import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../con import type { OpenClawConfig } from "../config/config.js"; import { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js"; import { formatErrorMessage } from "../infra/errors.js"; -import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; import type { SecurityAuditFinding, SecurityAuditSeverity } from "./audit.js"; import { resolveDmAllowState } from "./dm-policy-shared.js"; -import { - isDiscordMutableAllowEntry, - isZalouserMutableGroupEntry, -} from "./mutable-allowlist-detectors.js"; + +let auditChannelRuntimeModulePromise: + | Promise + | undefined; + +function loadAuditChannelRuntimeModule() { + auditChannelRuntimeModulePromise ??= import("./audit-channel.runtime.js"); + return auditChannelRuntimeModulePromise; +} function normalizeAllowFromList(list: Array | undefined | null): string[] { return normalizeStringEntries(Array.isArray(list) ? list : undefined); @@ -32,12 +32,13 @@ function addDiscordNameBasedEntries(params: { target: Set; values: unknown; source: string; + isDiscordMutableAllowEntry: (value: string) => boolean; }): void { if (!Array.isArray(params.values)) { return; } for (const value of params.values) { - if (!isDiscordMutableAllowEntry(String(value))) { + if (!params.isDiscordMutableAllowEntry(String(value))) { continue; } const text = String(value).trim(); @@ -52,25 +53,28 @@ function addZalouserMutableGroupEntries(params: { target: Set; groups: unknown; source: string; + isZalouserMutableGroupEntry: (value: string) => boolean; }): void { if (!params.groups || typeof params.groups !== "object" || Array.isArray(params.groups)) { return; } for (const key of Object.keys(params.groups as Record)) { - if (!isZalouserMutableGroupEntry(key)) { + if (!params.isZalouserMutableGroupEntry(key)) { continue; } params.target.add(`${params.source}:${key}`); } } -function collectInvalidTelegramAllowFromEntries(params: { +async function collectInvalidTelegramAllowFromEntries(params: { entries: unknown; target: Set; -}): void { +}): Promise { if (!Array.isArray(params.entries)) { return; } + const { isNumericTelegramUserId, normalizeTelegramAllowFromEntry } = + await loadAuditChannelRuntimeModule(); for (const entry of params.entries) { const normalized = normalizeTelegramAllowFromEntry(entry); if (!normalized || normalized === "*") { @@ -383,6 +387,8 @@ export async function collectChannelSecurityFindings(params: { } if (plugin.id === "discord") { + const { isDiscordMutableAllowEntry, readChannelAllowFromStore } = + await loadAuditChannelRuntimeModule(); const discordCfg = (account as { config?: Record } | null)?.config ?? ({} as Record); @@ -401,16 +407,19 @@ export async function collectChannelSecurityFindings(params: { target: discordNameBasedAllowEntries, values: discordCfg.allowFrom, source: `${discordPathPrefix}.allowFrom`, + isDiscordMutableAllowEntry, }); addDiscordNameBasedEntries({ target: discordNameBasedAllowEntries, values: (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom, source: `${discordPathPrefix}.dm.allowFrom`, + isDiscordMutableAllowEntry, }); addDiscordNameBasedEntries({ target: discordNameBasedAllowEntries, values: storeAllowFrom, source: "~/.openclaw/credentials/discord-allowFrom.json", + isDiscordMutableAllowEntry, }); const discordGuildEntries = (discordCfg.guilds as Record | undefined) ?? {}; @@ -423,6 +432,7 @@ export async function collectChannelSecurityFindings(params: { target: discordNameBasedAllowEntries, values: guild.users, source: `${discordPathPrefix}.guilds.${guildKey}.users`, + isDiscordMutableAllowEntry, }); const channels = guild.channels; if (!channels || typeof channels !== "object") { @@ -439,6 +449,7 @@ export async function collectChannelSecurityFindings(params: { target: discordNameBasedAllowEntries, values: channel.users, source: `${discordPathPrefix}.guilds.${guildKey}.channels.${channelKey}.users`, + isDiscordMutableAllowEntry, }); } } @@ -547,6 +558,7 @@ export async function collectChannelSecurityFindings(params: { } if (plugin.id === "zalouser") { + const { isZalouserMutableGroupEntry } = await loadAuditChannelRuntimeModule(); const zalouserCfg = (account as { config?: Record } | null)?.config ?? ({} as Record); @@ -560,6 +572,7 @@ export async function collectChannelSecurityFindings(params: { target: mutableGroupEntries, groups: zalouserCfg.groups, source: `${zalouserPathPrefix}.groups`, + isZalouserMutableGroupEntry, }); if (mutableGroupEntries.size > 0) { const examples = Array.from(mutableGroupEntries).slice(0, 5); @@ -586,6 +599,7 @@ export async function collectChannelSecurityFindings(params: { } if (plugin.id === "slack") { + const { readChannelAllowFromStore } = await loadAuditChannelRuntimeModule(); const slackCfg = (account as { config?: Record; dm?: Record } | null) ?.config ?? ({} as Record); @@ -724,6 +738,7 @@ export async function collectChannelSecurityFindings(params: { continue; } + const { readChannelAllowFromStore } = await loadAuditChannelRuntimeModule(); const storeAllowFrom = await readChannelAllowFromStore( "telegram", process.env, @@ -731,7 +746,7 @@ export async function collectChannelSecurityFindings(params: { ).catch(() => []); const storeHasWildcard = storeAllowFrom.some((v) => String(v).trim() === "*"); const invalidTelegramAllowFromEntries = new Set(); - collectInvalidTelegramAllowFromEntries({ + await collectInvalidTelegramAllowFromEntries({ entries: storeAllowFrom, target: invalidTelegramAllowFromEntries, }); @@ -739,48 +754,50 @@ export async function collectChannelSecurityFindings(params: { ? telegramCfg.groupAllowFrom : []; const groupAllowFromHasWildcard = groupAllowFrom.some((v) => String(v).trim() === "*"); - collectInvalidTelegramAllowFromEntries({ + await collectInvalidTelegramAllowFromEntries({ entries: groupAllowFrom, target: invalidTelegramAllowFromEntries, }); const dmAllowFrom = Array.isArray(telegramCfg.allowFrom) ? telegramCfg.allowFrom : []; - collectInvalidTelegramAllowFromEntries({ + await collectInvalidTelegramAllowFromEntries({ entries: dmAllowFrom, target: invalidTelegramAllowFromEntries, }); - const anyGroupOverride = Boolean( - groups && - Object.values(groups).some((value) => { + let anyGroupOverride = false; + if (groups) { + for (const value of Object.values(groups)) { if (!value || typeof value !== "object") { - return false; + continue; } const group = value as Record; const allowFrom = Array.isArray(group.allowFrom) ? group.allowFrom : []; if (allowFrom.length > 0) { - collectInvalidTelegramAllowFromEntries({ + anyGroupOverride = true; + await collectInvalidTelegramAllowFromEntries({ entries: allowFrom, target: invalidTelegramAllowFromEntries, }); - return true; } const topics = group.topics; if (!topics || typeof topics !== "object") { - return false; + continue; } - return Object.values(topics as Record).some((topicValue) => { + for (const topicValue of Object.values(topics as Record)) { if (!topicValue || typeof topicValue !== "object") { - return false; + continue; } const topic = topicValue as Record; const topicAllow = Array.isArray(topic.allowFrom) ? topic.allowFrom : []; - collectInvalidTelegramAllowFromEntries({ + if (topicAllow.length > 0) { + anyGroupOverride = true; + } + await collectInvalidTelegramAllowFromEntries({ entries: topicAllow, target: invalidTelegramAllowFromEntries, }); - return topicAllow.length > 0; - }); - }), - ); + } + } + } const hasAnySenderAllowlist = storeAllowFrom.length > 0 || groupAllowFrom.length > 0 || anyGroupOverride; From 7e8f5ca71b7de1490a0db7f627dbc32a76fdee86 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 04:13:32 +0000 Subject: [PATCH 183/943] fix(ui): centralize control model ref handling --- ui/src/ui/app-chat.test.ts | 16 +++- ui/src/ui/app-chat.ts | 8 +- ui/src/ui/app-render.helpers.ts | 33 +++---- ui/src/ui/app-view-state.ts | 3 +- ui/src/ui/app.ts | 3 +- ui/src/ui/chat-model-ref.test.ts | 50 ++++++++++ ui/src/ui/chat-model-ref.ts | 93 +++++++++++++++++++ .../chat/slash-command-executor.node.test.ts | 34 ++++++- ui/src/ui/chat/slash-command-executor.ts | 22 ++++- ui/src/ui/types.ts | 9 +- ui/src/ui/views/chat.browser.test.ts | 2 +- ui/src/ui/views/chat.test.ts | 84 ++++++++++++++--- ui/src/ui/views/sessions.test.ts | 2 +- 13 files changed, 310 insertions(+), 49 deletions(-) create mode 100644 ui/src/ui/chat-model-ref.test.ts create mode 100644 ui/src/ui/chat-model-ref.ts diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 9a3e86d375d..b0df28cd947 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -83,7 +83,14 @@ describe("handleSendChat", () => { ); const request = vi.fn(async (method: string, _params?: unknown) => { if (method === "sessions.patch") { - return { ok: true, key: "main" }; + return { + ok: true, + key: "main", + resolved: { + modelProvider: "openai", + model: "gpt-5-mini", + }, + }; } if (method === "chat.history") { return { messages: [], thinkingLevel: null }; @@ -93,7 +100,7 @@ describe("handleSendChat", () => { ts: 0, path: "", count: 0, - defaults: { model: "gpt-5", contextTokens: null }, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, sessions: [], }; } @@ -116,6 +123,9 @@ describe("handleSendChat", () => { key: "main", model: "gpt-5-mini", }); - expect(host.chatModelOverrides.main).toBe("gpt-5-mini"); + expect(host.chatModelOverrides.main).toEqual({ + kind: "qualified", + value: "openai/gpt-5-mini", + }); }); }); diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index c877b4c5a5d..ec5f7300000 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -10,7 +10,7 @@ import { loadModels } from "./controllers/models.ts"; import { loadSessions } from "./controllers/sessions.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import { normalizeBasePath } from "./navigation.ts"; -import type { ModelCatalogEntry } from "./types.ts"; +import type { ChatModelOverride, ModelCatalogEntry } from "./types.ts"; import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts"; import { generateUUID } from "./uuid.ts"; @@ -29,7 +29,7 @@ export type ChatHost = { basePath: string; hello: GatewayHelloOk | null; chatAvatarUrl: string | null; - chatModelOverrides: Record; + chatModelOverrides: Record; chatModelsLoading: boolean; chatModelCatalog: ModelCatalogEntry[]; updateComplete?: Promise; @@ -308,10 +308,10 @@ async function dispatchSlashCommand( injectCommandResult(host, result.content); } - if (result.sessionPatch && "model" in result.sessionPatch) { + if (result.sessionPatch && "modelOverride" in result.sessionPatch) { host.chatModelOverrides = { ...host.chatModelOverrides, - [targetSessionKey]: result.sessionPatch.model ?? null, + [targetSessionKey]: result.sessionPatch.modelOverride ?? null, }; } diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 12e239cb50d..e83825ab899 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -6,6 +6,13 @@ import { refreshChat } from "./app-chat.ts"; import { syncUrlWithSessionKey } from "./app-settings.ts"; import type { AppViewState } from "./app-view-state.ts"; import { OpenClawApp } from "./app.ts"; +import { + buildChatModelOption, + createChatModelOverride, + formatChatModelDisplay, + normalizeChatModelOverrideValue, + resolveServerChatModelValue, +} from "./chat-model-ref.ts"; import { ChatState, loadChatHistory } from "./controllers/chat.ts"; import { loadSessions } from "./controllers/sessions.ts"; import { icons } from "./icons.ts"; @@ -521,8 +528,8 @@ function resolveActiveSessionRow(state: AppViewState) { function resolveModelOverrideValue(state: AppViewState): string { // Prefer the local cache — it reflects in-flight patches before sessionsResult refreshes. const cached = state.chatModelOverrides[state.sessionKey]; - if (typeof cached === "string") { - return cached.trim(); + if (cached) { + return normalizeChatModelOverrideValue(cached, state.chatModelCatalog ?? []); } // cached === null means explicitly cleared to default. if (cached === null) { @@ -532,21 +539,14 @@ function resolveModelOverrideValue(state: AppViewState): string { // Include provider prefix so the value matches option keys (provider/model). const activeRow = resolveActiveSessionRow(state); if (activeRow && typeof activeRow.model === "string" && activeRow.model.trim()) { - const provider = activeRow.modelProvider?.trim(); - const model = activeRow.model.trim(); - return provider ? `${provider}/${model}` : model; + return resolveServerChatModelValue(activeRow.model, activeRow.modelProvider); } return ""; } function resolveDefaultModelValue(state: AppViewState): string { const defaults = state.sessionsResult?.defaults; - const model = defaults?.model; - if (typeof model !== "string" || !model.trim()) { - return ""; - } - const provider = defaults?.modelProvider?.trim(); - return provider ? `${provider}/${model.trim()}` : model.trim(); + return resolveServerChatModelValue(defaults?.model, defaults?.modelProvider); } function buildChatModelOptions( @@ -570,9 +570,8 @@ function buildChatModelOptions( }; for (const entry of catalog) { - const provider = entry.provider?.trim(); - const value = provider ? `${provider}/${entry.id}` : entry.id; - addOption(value, provider ? `${entry.id} · ${provider}` : entry.id); + const option = buildChatModelOption(entry); + addOption(option.value, option.label); } if (currentOverride) { @@ -592,9 +591,7 @@ function renderChatModelSelect(state: AppViewState) { currentOverride, defaultModel, ); - const defaultDisplay = defaultModel.includes("/") - ? `${defaultModel.slice(defaultModel.indexOf("/") + 1)} · ${defaultModel.slice(0, defaultModel.indexOf("/"))}` - : defaultModel; + const defaultDisplay = formatChatModelDisplay(defaultModel); const defaultLabel = defaultModel ? `Default (${defaultDisplay})` : "Default model"; const busy = state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null; @@ -639,7 +636,7 @@ async function switchChatModel(state: AppViewState, nextModel: string) { // Write the override cache immediately so the picker stays in sync during the RPC round-trip. state.chatModelOverrides = { ...state.chatModelOverrides, - [targetSessionKey]: nextModel || null, + [targetSessionKey]: createChatModelOverride(nextModel), }; try { await state.client.request("sessions.patch", { diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index ad2910625b6..375faa43137 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -21,6 +21,7 @@ import type { HealthSummary, LogEntry, LogLevel, + ChatModelOverride, ModelCatalogEntry, NostrProfile, PresenceEntry, @@ -71,7 +72,7 @@ export type AppViewState = { fallbackStatus: FallbackStatus | null; chatAvatarUrl: string | null; chatThinkingLevel: string | null; - chatModelOverrides: Record; + chatModelOverrides: Record; chatModelsLoading: boolean; chatModelCatalog: ModelCatalogEntry[]; chatQueue: ChatQueueItem[]; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 1b3971a41f6..af0d0cb9c96 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -69,6 +69,7 @@ import type { AgentIdentityResult, ConfigSnapshot, ConfigUiHints, + ChatModelOverride, CronJob, CronRunLogEntry, CronStatus, @@ -158,7 +159,7 @@ export class OpenClawApp extends LitElement { @state() fallbackStatus: FallbackStatus | null = null; @state() chatAvatarUrl: string | null = null; @state() chatThinkingLevel: string | null = null; - @state() chatModelOverrides: Record = {}; + @state() chatModelOverrides: Record = {}; @state() chatModelsLoading = false; @state() chatModelCatalog: ModelCatalogEntry[] = []; @state() chatQueue: ChatQueueItem[] = []; diff --git a/ui/src/ui/chat-model-ref.test.ts b/ui/src/ui/chat-model-ref.test.ts new file mode 100644 index 00000000000..86b46f3fe7f --- /dev/null +++ b/ui/src/ui/chat-model-ref.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + buildChatModelOption, + createChatModelOverride, + formatChatModelDisplay, + normalizeChatModelOverrideValue, + resolveServerChatModelValue, +} from "./chat-model-ref.ts"; +import type { ModelCatalogEntry } from "./types.ts"; + +const catalog: ModelCatalogEntry[] = [ + { id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openai" }, + { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", provider: "anthropic" }, +]; + +describe("chat-model-ref helpers", () => { + it("builds provider-qualified option values and labels", () => { + expect(buildChatModelOption(catalog[0])).toEqual({ + value: "openai/gpt-5-mini", + label: "gpt-5-mini · openai", + }); + }); + + it("normalizes raw overrides when the catalog match is unique", () => { + expect(normalizeChatModelOverrideValue(createChatModelOverride("gpt-5-mini"), catalog)).toBe( + "openai/gpt-5-mini", + ); + }); + + it("keeps ambiguous raw overrides unchanged", () => { + const ambiguousCatalog: ModelCatalogEntry[] = [ + { id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openai" }, + { id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openrouter" }, + ]; + + expect( + normalizeChatModelOverrideValue(createChatModelOverride("gpt-5-mini"), ambiguousCatalog), + ).toBe("gpt-5-mini"); + }); + + it("formats qualified model refs consistently for default labels", () => { + expect(formatChatModelDisplay("openai/gpt-5-mini")).toBe("gpt-5-mini · openai"); + expect(formatChatModelDisplay("alias-only")).toBe("alias-only"); + }); + + it("resolves server session data to qualified option values", () => { + expect(resolveServerChatModelValue("gpt-5-mini", "openai")).toBe("openai/gpt-5-mini"); + expect(resolveServerChatModelValue("alias-only", null)).toBe("alias-only"); + }); +}); diff --git a/ui/src/ui/chat-model-ref.ts b/ui/src/ui/chat-model-ref.ts new file mode 100644 index 00000000000..351b8544bad --- /dev/null +++ b/ui/src/ui/chat-model-ref.ts @@ -0,0 +1,93 @@ +import type { ModelCatalogEntry } from "./types.ts"; + +export type ChatModelOverride = + | { + kind: "qualified"; + value: string; + } + | { + kind: "raw"; + value: string; + }; + +export function buildQualifiedChatModelValue(model: string, provider?: string | null): string { + const trimmedModel = model.trim(); + if (!trimmedModel) { + return ""; + } + const trimmedProvider = provider?.trim(); + return trimmedProvider ? `${trimmedProvider}/${trimmedModel}` : trimmedModel; +} + +export function createChatModelOverride(value: string): ChatModelOverride | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + if (trimmed.includes("/")) { + return { kind: "qualified", value: trimmed }; + } + return { kind: "raw", value: trimmed }; +} + +export function normalizeChatModelOverrideValue( + override: ChatModelOverride | null | undefined, + catalog: ModelCatalogEntry[], +): string { + if (!override) { + return ""; + } + const trimmed = override?.value.trim(); + if (!trimmed) { + return ""; + } + if (override.kind === "qualified") { + return trimmed; + } + + let matchedValue = ""; + for (const entry of catalog) { + if (entry.id.trim().toLowerCase() !== trimmed.toLowerCase()) { + continue; + } + const candidate = buildQualifiedChatModelValue(entry.id, entry.provider); + if (!matchedValue) { + matchedValue = candidate; + continue; + } + if (matchedValue.toLowerCase() !== candidate.toLowerCase()) { + return trimmed; + } + } + return matchedValue || trimmed; +} + +export function resolveServerChatModelValue( + model?: string | null, + provider?: string | null, +): string { + if (typeof model !== "string") { + return ""; + } + return buildQualifiedChatModelValue(model, provider); +} + +export function formatChatModelDisplay(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + const separator = trimmed.indexOf("/"); + if (separator <= 0) { + return trimmed; + } + return `${trimmed.slice(separator + 1)} · ${trimmed.slice(0, separator)}`; +} + +export function buildChatModelOption(entry: ModelCatalogEntry): { value: string; label: string } { + const provider = entry.provider?.trim(); + return { + value: buildQualifiedChatModelValue(entry.id, provider), + label: provider ? `${entry.id} · ${provider}` : entry.id, + }; +} diff --git a/ui/src/ui/chat/slash-command-executor.node.test.ts b/ui/src/ui/chat/slash-command-executor.node.test.ts index d08c62b97d9..96170fa8940 100644 --- a/ui/src/ui/chat/slash-command-executor.node.test.ts +++ b/ui/src/ui/chat/slash-command-executor.node.test.ts @@ -235,7 +235,7 @@ describe("executeSlashCommand directives", () => { const request = vi.fn(async (method: string, _payload?: unknown) => { if (method === "sessions.list") { return { - defaults: { model: "default-model" }, + defaults: { modelProvider: "openai", model: "default-model" }, sessions: [ row("agent:main:main", { model: "gpt-4.1-mini", @@ -265,6 +265,38 @@ describe("executeSlashCommand directives", () => { expect(request).toHaveBeenNthCalledWith(2, "models.list", {}); }); + it("mirrors resolved provider-qualified model refs after /model changes", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.patch") { + return { + ok: true, + key: "main", + resolved: { + modelProvider: "openai", + model: "gpt-5-mini", + }, + }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "main", + "model", + "gpt-5-mini", + ); + + expect(request).toHaveBeenCalledWith("sessions.patch", { + key: "main", + model: "gpt-5-mini", + }); + expect(result.sessionPatch?.modelOverride).toEqual({ + kind: "qualified", + value: "openai/gpt-5-mini", + }); + }); + it("resolves the legacy main alias for /usage", async () => { const request = vi.fn(async (method: string, _payload?: unknown) => { if (method === "sessions.list") { diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index 38b1690fe29..1db10dd93d6 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -16,8 +16,15 @@ import { isSubagentSessionKey, parseAgentSessionKey, } from "../../../../src/routing/session-key.js"; +import { createChatModelOverride, resolveServerChatModelValue } from "../chat-model-ref.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; -import type { AgentsListResult, GatewaySessionRow, SessionsListResult } from "../types.ts"; +import type { + AgentsListResult, + ChatModelOverride, + GatewaySessionRow, + SessionsListResult, + SessionsPatchResult, +} from "../types.ts"; import { SLASH_COMMANDS } from "./slash-commands.ts"; export type SlashCommandResult = { @@ -35,7 +42,7 @@ export type SlashCommandResult = { | "navigate-usage"; /** Optional session-level directive changes that the caller should mirror locally. */ sessionPatch?: { - model?: string | null; + modelOverride?: ChatModelOverride | null; }; }; @@ -144,11 +151,18 @@ async function executeModel( } try { - await client.request("sessions.patch", { key: sessionKey, model: args.trim() }); + const patched = await client.request("sessions.patch", { + key: sessionKey, + model: args.trim(), + }); + const resolvedValue = resolveServerChatModelValue( + patched.resolved?.model ?? args.trim(), + patched.resolved?.modelProvider, + ); return { content: `Model set to \`${args.trim()}\`.`, action: "refresh", - sessionPatch: { model: args.trim() }, + sessionPatch: { modelOverride: createChatModelOverride(resolvedValue) }, }; } catch (err) { return { content: `Failed to set model: ${String(err)}` }; diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 82c97c6744a..0d5aa3d61cd 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -321,6 +321,8 @@ export type GatewaySessionsDefaults = { contextTokens: number | null; }; +export type ChatModelOverride = import("./chat-model-ref.ts").ChatModelOverride; + export type GatewayAgentRow = SharedGatewayAgentRow; export type AgentsListResult = { @@ -402,7 +404,12 @@ export type SessionsPatchResult = SessionsPatchResultBase<{ verboseLevel?: string; reasoningLevel?: string; elevatedLevel?: string; -}>; +}> & { + resolved?: { + modelProvider?: string; + model?: string; + }; +}; export type { CostUsageDailyEntry, diff --git a/ui/src/ui/views/chat.browser.test.ts b/ui/src/ui/views/chat.browser.test.ts index fa7947a328a..c17525bb60b 100644 --- a/ui/src/ui/views/chat.browser.test.ts +++ b/ui/src/ui/views/chat.browser.test.ts @@ -31,7 +31,7 @@ function createProps(overrides: Partial = {}): ChatProps { ts: 0, path: "", count: 1, - defaults: { model: "gpt-5", contextTokens: null }, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, sessions: [ { key: "main", diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index ab55db6935f..eea76e6482b 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -15,7 +15,7 @@ function createSessions(): SessionsListResult { ts: 0, path: "", count: 0, - defaults: { model: null, contextTokens: null }, + defaults: { modelProvider: null, model: null, contextTokens: null }, sessions: [], }; } @@ -28,6 +28,7 @@ function createChatHeaderState( } = {}, ): { state: AppViewState; request: ReturnType } { let currentModel = overrides.model ?? null; + let currentModelProvider = currentModel ? "openai" : undefined; const omitSessionFromList = overrides.omitSessionFromList ?? false; const catalog = overrides.models ?? [ { id: "gpt-5", name: "GPT-5", provider: "openai" }, @@ -35,7 +36,26 @@ function createChatHeaderState( ]; const request = vi.fn(async (method: string, params: Record) => { if (method === "sessions.patch") { - currentModel = (params.model as string | null | undefined) ?? null; + const nextModel = (params.model as string | null | undefined) ?? null; + if (!nextModel) { + currentModel = null; + currentModelProvider = undefined; + } else { + const normalized = nextModel.trim(); + const slashIndex = normalized.indexOf("/"); + if (slashIndex > 0) { + currentModelProvider = normalized.slice(0, slashIndex); + currentModel = normalized.slice(slashIndex + 1); + } else { + currentModel = normalized; + const matchingProviders = catalog + .filter((entry) => entry.id === normalized) + .map((entry) => entry.provider) + .filter(Boolean); + currentModelProvider = + matchingProviders.length === 1 ? matchingProviders[0] : currentModelProvider; + } + } return { ok: true, key: "main" }; } if (method === "chat.history") { @@ -46,10 +66,18 @@ function createChatHeaderState( ts: 0, path: "", count: omitSessionFromList ? 0 : 1, - defaults: { model: "gpt-5", contextTokens: null }, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, sessions: omitSessionFromList ? [] - : [{ key: "main", kind: "direct", updatedAt: null, model: currentModel }], + : [ + { + key: "main", + kind: "direct", + updatedAt: null, + modelProvider: currentModelProvider, + model: currentModel, + }, + ], }; } if (method === "models.list") { @@ -65,10 +93,18 @@ function createChatHeaderState( ts: 0, path: "", count: omitSessionFromList ? 0 : 1, - defaults: { model: "gpt-5", contextTokens: null }, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, sessions: omitSessionFromList ? [] - : [{ key: "main", kind: "direct", updatedAt: null, model: currentModel }], + : [ + { + key: "main", + kind: "direct", + updatedAt: null, + modelProvider: currentModelProvider, + model: currentModel, + }, + ], }, chatModelOverrides: {}, chatModelCatalog: catalog, @@ -566,13 +602,13 @@ describe("chat view", () => { expect(modelSelect).not.toBeNull(); expect(modelSelect?.value).toBe(""); - modelSelect!.value = "gpt-5-mini"; + modelSelect!.value = "openai/gpt-5-mini"; modelSelect!.dispatchEvent(new Event("change", { bubbles: true })); await flushTasks(); expect(request).toHaveBeenCalledWith("sessions.patch", { key: "main", - model: "gpt-5-mini", + model: "openai/gpt-5-mini", }); expect(request).not.toHaveBeenCalledWith("chat.history", expect.anything()); expect(state.sessionsResult?.sessions[0]?.model).toBe("gpt-5-mini"); @@ -594,7 +630,7 @@ describe("chat view", () => { 'select[data-chat-model-select="true"]', ); expect(modelSelect).not.toBeNull(); - expect(modelSelect?.value).toBe("gpt-5-mini"); + expect(modelSelect?.value).toBe("openai/gpt-5-mini"); modelSelect!.value = ""; modelSelect!.dispatchEvent(new Event("change", { bubbles: true })); @@ -638,7 +674,7 @@ describe("chat view", () => { ); expect(modelSelect).not.toBeNull(); - modelSelect!.value = "gpt-5-mini"; + modelSelect!.value = "openai/gpt-5-mini"; modelSelect!.dispatchEvent(new Event("change", { bubbles: true })); await flushTasks(); render(renderChatSessionSelect(state), container); @@ -646,10 +682,30 @@ describe("chat view", () => { const rerendered = container.querySelector( 'select[data-chat-model-select="true"]', ); - expect(rerendered?.value).toBe("gpt-5-mini"); + expect(rerendered?.value).toBe("openai/gpt-5-mini"); vi.unstubAllGlobals(); }); + it("normalizes cached bare /model overrides to the matching catalog option", () => { + const { state } = createChatHeaderState(); + state.chatModelOverrides = { main: { kind: "raw", value: "gpt-5-mini" } }; + + const container = document.createElement("div"); + render(renderChatSessionSelect(state), container); + + const modelSelect = container.querySelector( + 'select[data-chat-model-select="true"]', + ); + expect(modelSelect).not.toBeNull(); + expect(modelSelect?.value).toBe("openai/gpt-5-mini"); + + const optionValues = Array.from(modelSelect?.querySelectorAll("option") ?? []).map( + (option) => option.value, + ); + expect(optionValues).toContain("openai/gpt-5-mini"); + expect(optionValues).not.toContain("gpt-5-mini"); + }); + it("prefers the session label over displayName in the grouped chat session selector", () => { const { state } = createChatHeaderState({ omitSessionFromList: true }); state.sessionKey = "agent:main:subagent:4f2146de-887b-4176-9abe-91140082959b"; @@ -658,7 +714,7 @@ describe("chat view", () => { ts: 0, path: "", count: 1, - defaults: { model: "gpt-5", contextTokens: null }, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, sessions: [ { key: state.sessionKey, @@ -708,7 +764,7 @@ describe("chat view", () => { ts: 0, path: "", count: 1, - defaults: { model: "gpt-5", contextTokens: null }, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, sessions: [ { key: state.sessionKey, @@ -737,7 +793,7 @@ describe("chat view", () => { ts: 0, path: "", count: 2, - defaults: { model: "gpt-5", contextTokens: null }, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, sessions: [ { key: "agent:main:subagent:4f2146de-887b-4176-9abe-91140082959b", diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/ui/views/sessions.test.ts index fe650fef8fb..342af136a75 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -8,7 +8,7 @@ function buildResult(session: SessionsListResult["sessions"][number]): SessionsL ts: Date.now(), path: "(multiple)", count: 1, - defaults: { model: null, contextTokens: null }, + defaults: { modelProvider: null, model: null, contextTokens: null }, sessions: [session], }; } From cb4a298961ca1292e49afc8d010013f64cb06bcd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:11:39 -0700 Subject: [PATCH 184/943] CLI: route gateway status through daemon status --- src/cli/program/routes.test.ts | 62 ++++++++++++++++++++++++---------- src/cli/program/routes.ts | 30 ++++++++++------ 2 files changed, 64 insertions(+), 28 deletions(-) diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index 65cba06e299..87849fb4d0b 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -5,7 +5,7 @@ const runConfigGetMock = vi.hoisted(() => vi.fn(async () => {})); const runConfigUnsetMock = vi.hoisted(() => vi.fn(async () => {})); const modelsListCommandMock = vi.hoisted(() => vi.fn(async () => {})); const modelsStatusCommandMock = vi.hoisted(() => vi.fn(async () => {})); -const gatewayStatusCommandMock = vi.hoisted(() => vi.fn(async () => {})); +const runDaemonStatusMock = vi.hoisted(() => vi.fn(async () => {})); const statusJsonCommandMock = vi.hoisted(() => vi.fn(async () => {})); vi.mock("../config-cli.js", () => ({ @@ -18,8 +18,8 @@ vi.mock("../../commands/models.js", () => ({ modelsStatusCommand: modelsStatusCommandMock, })); -vi.mock("../../commands/gateway-status.js", () => ({ - gatewayStatusCommand: gatewayStatusCommandMock, +vi.mock("../daemon-cli/status.js", () => ({ + runDaemonStatus: runDaemonStatusMock, })); vi.mock("../../commands/status-json.js", () => ({ @@ -77,14 +77,24 @@ describe("program routes", () => { ["gateway", "status"], ["node", "openclaw", "gateway", "status", "--timeout"], ); - await expectRunFalse(["gateway", "status"], ["node", "openclaw", "gateway", "status", "--ssh"]); + }); + + it("returns false for gateway status route when probe-only flags are present", async () => { await expectRunFalse( ["gateway", "status"], - ["node", "openclaw", "gateway", "status", "--ssh-identity"], + ["node", "openclaw", "gateway", "status", "--ssh", "user@host"], + ); + await expectRunFalse( + ["gateway", "status"], + ["node", "openclaw", "gateway", "status", "--ssh-identity", "~/.ssh/id_test"], + ); + await expectRunFalse( + ["gateway", "status"], + ["node", "openclaw", "gateway", "status", "--ssh-auto"], ); }); - it("passes parsed gateway status flags through", async () => { + it("passes parsed gateway status flags through to daemon status", async () => { const route = expectRoute(["gateway", "status"]); await expect( route?.run([ @@ -102,27 +112,43 @@ describe("program routes", () => { "def", "--timeout", "5000", - "--ssh", - "user@host", - "--ssh-identity", - "~/.ssh/id_test", - "--ssh-auto", + "--deep", + "--require-rpc", "--json", ]), ).resolves.toBe(true); - expect(gatewayStatusCommandMock).toHaveBeenCalledWith( - { + expect(runDaemonStatusMock).toHaveBeenCalledWith({ + rpc: { url: "ws://127.0.0.1:18789", token: "abc", password: "def", timeout: "5000", - json: true, - ssh: "user@host", - sshIdentity: "~/.ssh/id_test", - sshAuto: true, }, - expect.any(Object), + probe: true, + requireRpc: true, + deep: true, + json: true, + }); + }); + + it("passes --no-probe through to daemon status", async () => { + const route = expectRoute(["gateway", "status"]); + await expect(route?.run(["node", "openclaw", "gateway", "status", "--no-probe"])).resolves.toBe( + true, ); + + expect(runDaemonStatusMock).toHaveBeenCalledWith({ + rpc: { + url: undefined, + token: undefined, + password: undefined, + timeout: undefined, + }, + probe: false, + requireRpc: false, + deep: false, + json: false, + }); }); it("returns false when status timeout flag value is missing", async () => { diff --git a/src/cli/program/routes.ts b/src/cli/program/routes.ts index 913f84dd2e4..cbb6d6dbfdc 100644 --- a/src/cli/program/routes.ts +++ b/src/cli/program/routes.ts @@ -81,26 +81,36 @@ const routeGatewayStatus: RouteSpec = { if (ssh === null) { return false; } + if (ssh !== undefined) { + return false; + } const sshIdentity = getFlagValue(argv, "--ssh-identity"); if (sshIdentity === null) { return false; } - const sshAuto = hasFlag(argv, "--ssh-auto"); + if (sshIdentity !== undefined) { + return false; + } + if (hasFlag(argv, "--ssh-auto")) { + return false; + } + const deep = hasFlag(argv, "--deep"); const json = hasFlag(argv, "--json"); - const { gatewayStatusCommand } = await import("../../commands/gateway-status.js"); - await gatewayStatusCommand( - { + const requireRpc = hasFlag(argv, "--require-rpc"); + const probe = !hasFlag(argv, "--no-probe"); + const { runDaemonStatus } = await import("../daemon-cli/status.js"); + await runDaemonStatus({ + rpc: { url: url ?? undefined, token: token ?? undefined, password: password ?? undefined, timeout: timeout ?? undefined, - json, - ssh: ssh ?? undefined, - sshIdentity: sshIdentity ?? undefined, - sshAuto, }, - defaultRuntime, - ); + probe, + requireRpc, + deep, + json, + }); return true; }, }; From 7781f62d33518a67e25309fa12c811d272e1cdb8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:28:56 -0700 Subject: [PATCH 185/943] Status: restore lazy scan runtime typing --- src/commands/status.scan.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 64a17e2b371..a74b9bbc131 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -48,6 +48,10 @@ type GatewayProbeSnapshot = { let pluginRegistryModulePromise: Promise | undefined; let statusScanRuntimeModulePromise: Promise | undefined; +type StatusScanRuntimeModule = typeof import("./status.scan.runtime.js"); +type ChannelStatusIssues = ReturnType; +type ChannelsTable = Awaited>; + function loadPluginRegistryModule() { pluginRegistryModulePromise ??= import("../cli/plugin-registry.js"); return pluginRegistryModulePromise; @@ -159,9 +163,9 @@ export type StatusScanResult = { gatewayProbe: Awaited> | null; gatewayReachable: boolean; gatewaySelf: ReturnType; - channelIssues: ReturnType; + channelIssues: ChannelStatusIssues; agentStatus: Awaited>; - channels: Awaited>; + channels: ChannelsTable; summary: Awaited>; memory: MemoryStatusSnapshot | null; memoryPlugin: MemoryPluginStatus; From 33edb57e745b4e3fab788248fa8b52cbb5727062 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:30:10 -0700 Subject: [PATCH 186/943] fix: keep provider resolution from clobbering channel plugins --- src/commands/status.scan.ts | 8 ++++---- src/plugins/provider-runtime.ts | 2 ++ src/plugins/providers.ts | 4 ++++ ui/src/ui/views/chat.test.ts | 1 + 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index a74b9bbc131..4ef90bf1da0 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -48,10 +48,6 @@ type GatewayProbeSnapshot = { let pluginRegistryModulePromise: Promise | undefined; let statusScanRuntimeModulePromise: Promise | undefined; -type StatusScanRuntimeModule = typeof import("./status.scan.runtime.js"); -type ChannelStatusIssues = ReturnType; -type ChannelsTable = Awaited>; - function loadPluginRegistryModule() { pluginRegistryModulePromise ??= import("../cli/plugin-registry.js"); return pluginRegistryModulePromise; @@ -62,6 +58,10 @@ function loadStatusScanRuntimeModule() { return statusScanRuntimeModulePromise; } +type StatusScanRuntimeModule = Awaited>; +type ChannelStatusIssues = ReturnType; +type ChannelsTable = Awaited>; + function deferResult(promise: Promise): Promise> { return promise.then( (value) => ({ ok: true, value }), diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 8997011a7c9..41c0a70ec4d 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -39,6 +39,8 @@ function resolveProviderPluginsForHooks(params: { }): ProviderPlugin[] { return resolvePluginProviders({ ...params, + activate: false, + cache: false, bundledProviderAllowlistCompat: true, bundledProviderVitestCompat: true, }); diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index e3215f2c6da..37f937d5a91 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -122,6 +122,8 @@ export function resolvePluginProviders(params: { bundledProviderAllowlistCompat?: boolean; bundledProviderVitestCompat?: boolean; onlyPluginIds?: string[]; + activate?: boolean; + cache?: boolean; }): ProviderPlugin[] { const maybeAllowlistCompat = params.bundledProviderAllowlistCompat ? withBundledPluginAllowlistCompat({ @@ -140,6 +142,8 @@ export function resolvePluginProviders(params: { workspaceDir: params.workspaceDir, env: params.env, onlyPluginIds: params.onlyPluginIds, + activate: params.activate, + cache: params.cache, logger: createPluginLoaderLogger(log), }); diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index eea76e6482b..6907cafa0ed 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -612,6 +612,7 @@ describe("chat view", () => { }); expect(request).not.toHaveBeenCalledWith("chat.history", expect.anything()); expect(state.sessionsResult?.sessions[0]?.model).toBe("gpt-5-mini"); + expect(state.sessionsResult?.sessions[0]?.modelProvider).toBe("openai"); vi.unstubAllGlobals(); }); From b8bb8510a2a382632cae058200c9e90a591e1ffd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:35:20 -0700 Subject: [PATCH 187/943] feat: move ssh sandboxing into core --- CHANGELOG.md | 1 + docs/cli/sandbox.md | 19 +- docs/gateway/configuration-reference.md | 35 +- docs/gateway/sandboxing.md | 55 ++ docs/gateway/secrets.md | 3 + extensions/openshell/src/backend.test.ts | 1 + extensions/openshell/src/backend.ts | 41 +- extensions/openshell/src/cli.ts | 124 +--- extensions/openshell/src/remote-fs-bridge.ts | 554 +----------------- src/agents/sandbox-merge.test.ts | 36 ++ src/agents/sandbox.ts | 19 + src/agents/sandbox/backend.ts | 12 +- src/agents/sandbox/browser.create.test.ts | 6 + src/agents/sandbox/config.ts | 59 ++ .../docker.config-hash-recreate.test.ts | 6 + src/agents/sandbox/manage.ts | 9 +- src/agents/sandbox/prune.ts | 9 +- src/agents/sandbox/remote-fs-bridge.ts | 518 ++++++++++++++++ src/agents/sandbox/ssh-backend.ts | 303 ++++++++++ src/agents/sandbox/ssh.test.ts | 61 ++ src/agents/sandbox/ssh.ts | 334 +++++++++++ src/agents/sandbox/types.ts | 15 + src/config/types.agents-shared.ts | 3 + src/config/types.sandbox.ts | 27 + src/config/zod-schema.agent-runtime.ts | 18 + src/plugin-sdk/core.ts | 15 + src/secrets/runtime-config-collectors-core.ts | 85 +++ src/secrets/runtime.test.ts | 40 ++ 28 files changed, 1724 insertions(+), 684 deletions(-) create mode 100644 src/agents/sandbox/remote-fs-bridge.ts create mode 100644 src/agents/sandbox/ssh-backend.ts create mode 100644 src/agents/sandbox/ssh.test.ts create mode 100644 src/agents/sandbox/ssh.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 07937512400..ea4239d1e8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus. - secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. - Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend with `mirror` and `remote` workspace modes, and make sandbox list/recreate/prune backend-aware instead of Docker-only. +- Sandbox/SSH: add a core SSH sandbox backend with secret-backed key, certificate, and known_hosts inputs, move shared remote exec/filesystem tooling into core, and keep OpenShell focused on sandbox lifecycle plus optional `mirror` mode. ### Fixes diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 5ebac698175..f320be3b771 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -16,6 +16,7 @@ OpenClaw can run agents in isolated sandbox runtimes for security. The `sandbox` Today that usually means: - Docker sandbox containers +- SSH sandbox runtimes when `agents.defaults.sandbox.backend = "ssh"` - OpenShell sandbox runtimes when `agents.defaults.sandbox.backend = "openshell"` ## Commands @@ -97,6 +98,22 @@ openclaw sandbox recreate --all openclaw sandbox recreate --all ``` +### After changing SSH target or SSH auth material + +```bash +# Edit config: +# - agents.defaults.sandbox.backend +# - agents.defaults.sandbox.ssh.target +# - agents.defaults.sandbox.ssh.workspaceRoot +# - agents.defaults.sandbox.ssh.identityFile / certificateFile / knownHostsFile +# - agents.defaults.sandbox.ssh.identityData / certificateData / knownHostsData + +openclaw sandbox recreate --all +``` + +For the core `ssh` backend, recreate deletes the per-scope remote workspace root +on the SSH target. The next run seeds it again from the local workspace. + ### After changing OpenShell source, policy, or mode ```bash @@ -150,7 +167,7 @@ Sandbox settings live in `~/.openclaw/openclaw.json` under `agents.defaults.sand "defaults": { "sandbox": { "mode": "all", // off, non-main, all - "backend": "docker", // docker, openshell + "backend": "docker", // docker, ssh, openshell "scope": "agent", // session, agent, shared "docker": { "image": "openclaw-sandbox:bookworm-slim", diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 951f99f1165..ecefd8bbc4e 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1125,7 +1125,7 @@ Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing defaults: { sandbox: { mode: "non-main", // off | non-main | all - backend: "docker", // docker | openshell + backend: "docker", // docker | ssh | openshell scope: "agent", // session | agent | shared workspaceAccess: "none", // none | ro | rw workspaceRoot: "~/.openclaw/sandboxes", @@ -1154,6 +1154,20 @@ Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing extraHosts: ["internal.service:10.0.0.5"], binds: ["/home/user/source:/source:rw"], }, + ssh: { + target: "user@gateway-host:22", + command: "ssh", + workspaceRoot: "/tmp/openclaw-sandboxes", + strictHostKeyChecking: true, + updateHostKeys: true, + identityFile: "~/.ssh/id_ed25519", + certificateFile: "~/.ssh/id_ed25519-cert.pub", + knownHostsFile: "~/.ssh/known_hosts", + // SecretRefs / inline contents also supported: + // identityData: { source: "env", provider: "default", id: "SSH_IDENTITY" }, + // certificateData: { source: "env", provider: "default", id: "SSH_CERTIFICATE" }, + // knownHostsData: { source: "env", provider: "default", id: "SSH_KNOWN_HOSTS" }, + }, browser: { enabled: false, image: "openclaw-sandbox-browser:bookworm-slim", @@ -1203,11 +1217,29 @@ Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing **Backend:** - `docker`: local Docker runtime (default) +- `ssh`: generic SSH-backed remote runtime - `openshell`: OpenShell runtime When `backend: "openshell"` is selected, runtime-specific settings move to `plugins.entries.openshell.config`. +**SSH backend config:** + +- `target`: SSH target in `user@host[:port]` form +- `command`: SSH client command (default: `ssh`) +- `workspaceRoot`: absolute remote root used for per-scope workspaces +- `identityFile` / `certificateFile` / `knownHostsFile`: existing local files passed to OpenSSH +- `identityData` / `certificateData` / `knownHostsData`: inline contents or SecretRefs that OpenClaw materializes into temp files at runtime +- `strictHostKeyChecking` / `updateHostKeys`: OpenSSH host-key policy knobs + +**SSH backend behavior:** + +- seeds the remote workspace once after create or recreate +- then keeps the remote SSH workspace canonical +- routes `exec`, file tools, and media paths over SSH +- does not sync remote changes back to the host automatically +- does not support sandbox browser containers + **Workspace access:** - `none`: per-scope sandbox workspace under `~/.openclaw/sandboxes` @@ -1252,6 +1284,7 @@ When `backend: "openshell"` is selected, runtime-specific settings move to - `remote`: seed remote once when the sandbox is created, then keep the remote workspace canonical In `remote` mode, host-local edits made outside OpenClaw are not synced into the sandbox automatically after the seed step. +Transport is SSH into the OpenShell sandbox, but the plugin owns sandbox lifecycle and optional mirror sync. **`setupCommand`** runs once after container creation (via `sh -lc`). Needs network egress, writable root, root user. diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index db40b802832..b37757334c0 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -59,10 +59,61 @@ Not sandboxed: `agents.defaults.sandbox.backend` controls **which runtime** provides the sandbox: - `"docker"` (default): local Docker-backed sandbox runtime. +- `"ssh"`: generic SSH-backed remote sandbox runtime. - `"openshell"`: OpenShell-backed sandbox runtime. +SSH-specific config lives under `agents.defaults.sandbox.ssh`. OpenShell-specific config lives under `plugins.entries.openshell.config`. +### SSH backend + +Use `backend: "ssh"` when you want OpenClaw to sandbox `exec`, file tools, and media reads on +an arbitrary SSH-accessible machine. + +```json5 +{ + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "ssh", + scope: "session", + workspaceAccess: "rw", + ssh: { + target: "user@gateway-host:22", + workspaceRoot: "/tmp/openclaw-sandboxes", + strictHostKeyChecking: true, + updateHostKeys: true, + identityFile: "~/.ssh/id_ed25519", + certificateFile: "~/.ssh/id_ed25519-cert.pub", + knownHostsFile: "~/.ssh/known_hosts", + // Or use SecretRefs / inline contents instead of local files: + // identityData: { source: "env", provider: "default", id: "SSH_IDENTITY" }, + // certificateData: { source: "env", provider: "default", id: "SSH_CERTIFICATE" }, + // knownHostsData: { source: "env", provider: "default", id: "SSH_KNOWN_HOSTS" }, + }, + }, + }, + }, +} +``` + +How it works: + +- OpenClaw creates a per-scope remote root under `sandbox.ssh.workspaceRoot`. +- On first use after create or recreate, OpenClaw seeds that remote workspace from the local workspace once. +- After that, `exec`, `read`, `write`, `edit`, `apply_patch`, prompt media reads, and inbound media staging run directly against the remote workspace over SSH. +- OpenClaw does not sync remote changes back to the local workspace automatically. + +This is a **remote-canonical** model. The remote SSH workspace becomes the real sandbox state after the initial seed. + +Important consequences: + +- Host-local edits made outside OpenClaw after the seed step are not visible remotely until you recreate the sandbox. +- `openclaw sandbox recreate` deletes the per-scope remote root and seeds again from local on next use. +- Browser sandboxing is not supported on the SSH backend. +- `sandbox.docker.*` settings do not apply to the SSH backend. + ```json5 { agents: { @@ -96,6 +147,9 @@ OpenShell modes: - `mirror` (default): local workspace stays canonical. OpenClaw syncs local files into OpenShell before exec and syncs the remote workspace back after exec. - `remote`: OpenShell workspace is canonical after the sandbox is created. OpenClaw seeds the remote workspace once from the local workspace, then file tools and exec run directly against the remote sandbox without syncing changes back. +OpenShell reuses the same core SSH transport and remote filesystem bridge as the generic SSH backend. +The plugin adds OpenShell-specific lifecycle (`sandbox create/get/delete`, `sandbox ssh-config`) and the optional `mirror` mode. + Current OpenShell limitations: - sandbox browser is not supported yet @@ -136,6 +190,7 @@ Behavior: - After that, `exec`, `read`, `write`, `edit`, and `apply_patch` operate directly against the remote OpenShell workspace. - OpenClaw does **not** sync remote changes back into the local workspace after exec. - Prompt-time media reads still work because file and media tools read through the sandbox bridge instead of assuming a local host path. +- Transport is SSH into the OpenShell sandbox returned by `openshell sandbox ssh-config`. Important consequences: diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 379e4a527d4..eb044eaf03c 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -41,6 +41,9 @@ Examples of inactive surfaces: - Web search provider-specific keys that are not selected by `tools.web.search.provider`. In auto mode (provider unset), keys are consulted by precedence for provider auto-detection until one resolves. After selection, non-selected provider keys are treated as inactive until selected. +- Sandbox SSH auth material (`agents.defaults.sandbox.ssh.identityData`, + `certificateData`, `knownHostsData`, plus per-agent overrides) is active only + when the effective sandbox backend is `ssh` for the default agent or an enabled agent. - `gateway.remote.token` / `gateway.remote.password` SecretRefs are active if one of these is true: - `gateway.mode=remote` - `gateway.remote.url` is configured diff --git a/extensions/openshell/src/backend.test.ts b/extensions/openshell/src/backend.test.ts index 2999599c648..2685d7effa8 100644 --- a/extensions/openshell/src/backend.test.ts +++ b/extensions/openshell/src/backend.test.ts @@ -101,6 +101,7 @@ describe("openshell backend manager", () => { image: "openclaw", configLabelKind: "Source", }, + config: {}, }); expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({ diff --git a/extensions/openshell/src/backend.ts b/extensions/openshell/src/backend.ts index 85c3d415904..d87b1c92af8 100644 --- a/extensions/openshell/src/backend.ts +++ b/extensions/openshell/src/backend.ts @@ -4,43 +4,44 @@ import path from "node:path"; import type { CreateSandboxBackendParams, OpenClawConfig, + RemoteShellSandboxHandle, SandboxBackendCommandParams, SandboxBackendCommandResult, SandboxBackendFactory, SandboxBackendHandle, SandboxBackendManager, + SshSandboxSession, +} from "openclaw/plugin-sdk/core"; +import { + createRemoteShellSandboxFsBridge, + disposeSshSandboxSession, + resolvePreferredOpenClawTmpDir, + runSshSandboxCommand, } from "openclaw/plugin-sdk/core"; -import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/core"; import { buildExecRemoteCommand, buildRemoteCommand, createOpenShellSshSession, - disposeOpenShellSshSession, runOpenShellCli, - runOpenShellSshCommand, type OpenShellExecContext, - type OpenShellSshSession, } from "./cli.js"; import { resolveOpenShellPluginConfig, type ResolvedOpenShellPluginConfig } from "./config.js"; import { createOpenShellFsBridge } from "./fs-bridge.js"; import { replaceDirectoryContents } from "./mirror.js"; -import { createOpenShellRemoteFsBridge } from "./remote-fs-bridge.js"; type CreateOpenShellSandboxBackendFactoryParams = { pluginConfig: ResolvedOpenShellPluginConfig; }; type PendingExec = { - sshSession: OpenShellSshSession; + sshSession: SshSandboxSession; }; -export type OpenShellSandboxBackend = SandboxBackendHandle & { - mode: "mirror" | "remote"; - remoteWorkspaceDir: string; - remoteAgentWorkspaceDir: string; - runRemoteShellScript(params: SandboxBackendCommandParams): Promise; - syncLocalPathToRemote(localPath: string, remotePath: string): Promise; -}; +export type OpenShellSandboxBackend = SandboxBackendHandle & + RemoteShellSandboxHandle & { + mode: "mirror" | "remote"; + syncLocalPathToRemote(localPath: string, remotePath: string): Promise; + }; export function createOpenShellSandboxBackendFactory( params: CreateOpenShellSandboxBackendFactoryParams, @@ -129,9 +130,9 @@ async function createOpenShellSandboxBackend(params: { runShellCommand: async (command) => await impl.runRemoteShellScript(command), createFsBridge: ({ sandbox }) => params.pluginConfig.mode === "remote" - ? createOpenShellRemoteFsBridge({ + ? createRemoteShellSandboxFsBridge({ sandbox, - backend: impl.asHandle(), + runtime: impl.asHandle(), }) : createOpenShellFsBridge({ sandbox, @@ -186,9 +187,9 @@ class OpenShellSandboxBackendImpl { runShellCommand: async (command) => await self.runRemoteShellScript(command), createFsBridge: ({ sandbox }) => this.params.execContext.config.mode === "remote" - ? createOpenShellRemoteFsBridge({ + ? createRemoteShellSandboxFsBridge({ sandbox, - backend: self.asHandle(), + runtime: self.asHandle(), }) : createOpenShellFsBridge({ sandbox, @@ -242,7 +243,7 @@ class OpenShellSandboxBackendImpl { } } finally { if (token?.sshSession) { - await disposeOpenShellSshSession(token.sshSession); + await disposeSshSandboxSession(token.sshSession); } } } @@ -262,7 +263,7 @@ class OpenShellSandboxBackendImpl { context: this.params.execContext, }); try { - return await runOpenShellSshCommand({ + return await runSshSandboxCommand({ session, remoteCommand: buildRemoteCommand([ "/bin/sh", @@ -276,7 +277,7 @@ class OpenShellSandboxBackendImpl { signal: params.signal, }); } finally { - await disposeOpenShellSshSession(session); + await disposeSshSandboxSession(session); } } diff --git a/extensions/openshell/src/cli.ts b/extensions/openshell/src/cli.ts index 8f9808b5164..411166520e7 100644 --- a/extensions/openshell/src/cli.ts +++ b/extensions/openshell/src/cli.ts @@ -1,34 +1,20 @@ -import { spawn } from "node:child_process"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { - resolvePreferredOpenClawTmpDir, + buildExecRemoteCommand, + createSshSandboxSessionFromConfigText, runPluginCommandWithTimeout, + shellEscape, + type SshSandboxSession, } from "openclaw/plugin-sdk/core"; -import type { SandboxBackendCommandResult } from "openclaw/plugin-sdk/core"; import type { ResolvedOpenShellPluginConfig } from "./config.js"; +export { buildExecRemoteCommand, shellEscape } from "openclaw/plugin-sdk/core"; + export type OpenShellExecContext = { config: ResolvedOpenShellPluginConfig; sandboxName: string; timeoutMs?: number; }; -export type OpenShellSshSession = { - configPath: string; - host: string; -}; - -export type OpenShellRunSshCommandParams = { - session: OpenShellSshSession; - remoteCommand: string; - stdin?: Buffer | string; - allowFailure?: boolean; - signal?: AbortSignal; - tty?: boolean; -}; - export function buildOpenShellBaseArgv(config: ResolvedOpenShellPluginConfig): string[] { const argv = [config.command]; if (config.gateway) { @@ -40,10 +26,6 @@ export function buildOpenShellBaseArgv(config: ResolvedOpenShellPluginConfig): s return argv; } -export function shellEscape(value: string): string { - return `'${value.replaceAll("'", `'\"'\"'`)}'`; -} - export function buildRemoteCommand(argv: string[]): string { return argv.map((entry) => shellEscape(entry)).join(" "); } @@ -64,7 +46,7 @@ export async function runOpenShellCli(params: { export async function createOpenShellSshSession(params: { context: OpenShellExecContext; -}): Promise { +}): Promise { const result = await runOpenShellCli({ context: params.context, args: ["sandbox", "ssh-config", params.context.sandboxName], @@ -72,95 +54,7 @@ export async function createOpenShellSshSession(params: { if (result.code !== 0) { throw new Error(result.stderr.trim() || "openshell sandbox ssh-config failed"); } - const hostMatch = result.stdout.match(/^\s*Host\s+(\S+)/m); - const host = hostMatch?.[1]?.trim(); - if (!host) { - throw new Error("Failed to parse openshell ssh-config output."); - } - const tmpRoot = resolvePreferredOpenClawTmpDir() || os.tmpdir(); - await fs.mkdir(tmpRoot, { recursive: true }); - const configDir = await fs.mkdtemp(path.join(tmpRoot, "openclaw-openshell-ssh-")); - const configPath = path.join(configDir, "config"); - await fs.writeFile(configPath, result.stdout, "utf8"); - return { configPath, host }; -} - -export async function disposeOpenShellSshSession(session: OpenShellSshSession): Promise { - await fs.rm(path.dirname(session.configPath), { recursive: true, force: true }); -} - -export async function runOpenShellSshCommand( - params: OpenShellRunSshCommandParams, -): Promise { - const argv = [ - "ssh", - "-F", - params.session.configPath, - ...(params.tty - ? ["-tt", "-o", "RequestTTY=force", "-o", "SetEnv=TERM=xterm-256color"] - : ["-T", "-o", "RequestTTY=no"]), - params.session.host, - params.remoteCommand, - ]; - - const result = await new Promise((resolve, reject) => { - const child = spawn(argv[0]!, argv.slice(1), { - stdio: ["pipe", "pipe", "pipe"], - env: process.env, - signal: params.signal, - }); - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - - child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); - child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); - child.on("error", reject); - child.on("close", (code) => { - const stdout = Buffer.concat(stdoutChunks); - const stderr = Buffer.concat(stderrChunks); - const exitCode = code ?? 0; - if (exitCode !== 0 && !params.allowFailure) { - const error = Object.assign( - new Error(stderr.toString("utf8").trim() || `ssh exited with code ${exitCode}`), - { - code: exitCode, - stdout, - stderr, - }, - ); - reject(error); - return; - } - resolve({ stdout, stderr, code: exitCode }); - }); - - if (params.stdin !== undefined) { - child.stdin.end(params.stdin); - return; - } - child.stdin.end(); + return await createSshSandboxSessionFromConfigText({ + configText: result.stdout, }); - - return result; -} - -export function buildExecRemoteCommand(params: { - command: string; - workdir?: string; - env: Record; -}): string { - const body = params.workdir - ? `cd ${shellEscape(params.workdir)} && ${params.command}` - : params.command; - const argv = - Object.keys(params.env).length > 0 - ? [ - "env", - ...Object.entries(params.env).map(([key, value]) => `${key}=${value}`), - "/bin/sh", - "-c", - body, - ] - : ["/bin/sh", "-c", body]; - return buildRemoteCommand(argv); } diff --git a/extensions/openshell/src/remote-fs-bridge.ts b/extensions/openshell/src/remote-fs-bridge.ts index 3560fa78f28..9cc1ddf704d 100644 --- a/extensions/openshell/src/remote-fs-bridge.ts +++ b/extensions/openshell/src/remote-fs-bridge.ts @@ -1,550 +1,16 @@ -import path from "node:path"; -import type { - SandboxContext, - SandboxFsBridge, - SandboxFsStat, - SandboxResolvedPath, +import { + createRemoteShellSandboxFsBridge, + type RemoteShellSandboxHandle, + type SandboxContext, + type SandboxFsBridge, } from "openclaw/plugin-sdk/core"; -import { SANDBOX_PINNED_MUTATION_PYTHON } from "../../../src/agents/sandbox/fs-bridge-mutation-helper.js"; -import type { OpenShellSandboxBackend } from "./backend.js"; - -type ResolvedRemotePath = SandboxResolvedPath & { - writable: boolean; - mountRootPath: string; - source: "workspace" | "agent"; -}; - -type MountInfo = { - containerRoot: string; - writable: boolean; - source: "workspace" | "agent"; -}; export function createOpenShellRemoteFsBridge(params: { sandbox: SandboxContext; - backend: OpenShellSandboxBackend; + backend: RemoteShellSandboxHandle; }): SandboxFsBridge { - return new OpenShellRemoteFsBridge(params.sandbox, params.backend); -} - -class OpenShellRemoteFsBridge implements SandboxFsBridge { - constructor( - private readonly sandbox: SandboxContext, - private readonly backend: OpenShellSandboxBackend, - ) {} - - resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath { - const target = this.resolveTarget(params); - return { - relativePath: target.relativePath, - containerPath: target.containerPath, - }; - } - - async readFile(params: { - filePath: string; - cwd?: string; - signal?: AbortSignal; - }): Promise { - const target = this.resolveTarget(params); - const canonical = await this.resolveCanonicalPath({ - containerPath: target.containerPath, - action: "read files", - }); - await this.assertNoHardlinkedFile({ - containerPath: canonical, - action: "read files", - signal: params.signal, - }); - const result = await this.runRemoteScript({ - script: 'set -eu\ncat -- "$1"', - args: [canonical], - signal: params.signal, - }); - return result.stdout; - } - - async writeFile(params: { - filePath: string; - cwd?: string; - data: Buffer | string; - encoding?: BufferEncoding; - mkdir?: boolean; - signal?: AbortSignal; - }): Promise { - const target = this.resolveTarget(params); - this.ensureWritable(target, "write files"); - const pinned = await this.resolvePinnedParent({ - containerPath: target.containerPath, - action: "write files", - requireWritable: true, - }); - await this.assertNoHardlinkedFile({ - containerPath: target.containerPath, - action: "write files", - signal: params.signal, - }); - const buffer = Buffer.isBuffer(params.data) - ? params.data - : Buffer.from(params.data, params.encoding ?? "utf8"); - await this.runMutation({ - args: [ - "write", - pinned.mountRootPath, - pinned.relativeParentPath, - pinned.basename, - params.mkdir !== false ? "1" : "0", - ], - stdin: buffer, - signal: params.signal, - }); - } - - async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise { - const target = this.resolveTarget(params); - this.ensureWritable(target, "create directories"); - const relativePath = path.posix.relative(target.mountRootPath, target.containerPath); - if (relativePath.startsWith("..") || path.posix.isAbsolute(relativePath)) { - throw new Error( - `Sandbox path escapes allowed mounts; cannot create directories: ${target.containerPath}`, - ); - } - await this.runMutation({ - args: ["mkdirp", target.mountRootPath, relativePath === "." ? "" : relativePath], - signal: params.signal, - }); - } - - async remove(params: { - filePath: string; - cwd?: string; - recursive?: boolean; - force?: boolean; - signal?: AbortSignal; - }): Promise { - const target = this.resolveTarget(params); - this.ensureWritable(target, "remove files"); - const exists = await this.remotePathExists(target.containerPath, params.signal); - if (!exists) { - if (params.force === false) { - throw new Error(`Sandbox path not found; cannot remove files: ${target.containerPath}`); - } - return; - } - const pinned = await this.resolvePinnedParent({ - containerPath: target.containerPath, - action: "remove files", - requireWritable: true, - allowFinalSymlinkForUnlink: true, - }); - await this.runMutation({ - args: [ - "remove", - pinned.mountRootPath, - pinned.relativeParentPath, - pinned.basename, - params.recursive ? "1" : "0", - params.force === false ? "0" : "1", - ], - signal: params.signal, - allowFailure: params.force !== false, - }); - } - - async rename(params: { - from: string; - to: string; - cwd?: string; - signal?: AbortSignal; - }): Promise { - const from = this.resolveTarget({ filePath: params.from, cwd: params.cwd }); - const to = this.resolveTarget({ filePath: params.to, cwd: params.cwd }); - this.ensureWritable(from, "rename files"); - this.ensureWritable(to, "rename files"); - const fromPinned = await this.resolvePinnedParent({ - containerPath: from.containerPath, - action: "rename files", - requireWritable: true, - allowFinalSymlinkForUnlink: true, - }); - const toPinned = await this.resolvePinnedParent({ - containerPath: to.containerPath, - action: "rename files", - requireWritable: true, - }); - await this.runMutation({ - args: [ - "rename", - fromPinned.mountRootPath, - fromPinned.relativeParentPath, - fromPinned.basename, - toPinned.mountRootPath, - toPinned.relativeParentPath, - toPinned.basename, - "1", - ], - signal: params.signal, - }); - } - - async stat(params: { - filePath: string; - cwd?: string; - signal?: AbortSignal; - }): Promise { - const target = this.resolveTarget(params); - const exists = await this.remotePathExists(target.containerPath, params.signal); - if (!exists) { - return null; - } - const canonical = await this.resolveCanonicalPath({ - containerPath: target.containerPath, - action: "stat files", - signal: params.signal, - }); - await this.assertNoHardlinkedFile({ - containerPath: canonical, - action: "stat files", - signal: params.signal, - }); - const result = await this.runRemoteScript({ - script: 'set -eu\nstat -c "%F|%s|%Y" -- "$1"', - args: [canonical], - signal: params.signal, - }); - const output = result.stdout.toString("utf8").trim(); - const [kindRaw = "", sizeRaw = "0", mtimeRaw = "0"] = output.split("|"); - return { - type: kindRaw === "directory" ? "directory" : kindRaw === "regular file" ? "file" : "other", - size: Number(sizeRaw), - mtimeMs: Number(mtimeRaw) * 1000, - }; - } - - private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedRemotePath { - const workspaceRoot = path.resolve(this.sandbox.workspaceDir); - const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir); - const workspaceContainerRoot = normalizeContainerPath(this.backend.remoteWorkspaceDir); - const agentContainerRoot = normalizeContainerPath(this.backend.remoteAgentWorkspaceDir); - const mounts: MountInfo[] = [ - { - containerRoot: workspaceContainerRoot, - writable: this.sandbox.workspaceAccess === "rw", - source: "workspace", - }, - ]; - if ( - this.sandbox.workspaceAccess !== "none" && - path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir) - ) { - mounts.push({ - containerRoot: agentContainerRoot, - writable: this.sandbox.workspaceAccess === "rw", - source: "agent", - }); - } - - const input = params.filePath.trim(); - const inputPosix = input.replace(/\\/g, "/"); - const maybeContainerMount = path.posix.isAbsolute(inputPosix) - ? this.resolveMountByContainerPath(mounts, normalizeContainerPath(inputPosix)) - : null; - if (maybeContainerMount) { - return this.toResolvedPath({ - mount: maybeContainerMount, - containerPath: normalizeContainerPath(inputPosix), - }); - } - - const hostCwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot; - const hostCandidate = path.isAbsolute(input) - ? path.resolve(input) - : path.resolve(hostCwd, input); - if (isPathInside(workspaceRoot, hostCandidate)) { - const relative = toPosixRelative(workspaceRoot, hostCandidate); - return this.toResolvedPath({ - mount: mounts[0]!, - containerPath: relative - ? path.posix.join(workspaceContainerRoot, relative) - : workspaceContainerRoot, - }); - } - if (mounts[1] && isPathInside(agentRoot, hostCandidate)) { - const relative = toPosixRelative(agentRoot, hostCandidate); - return this.toResolvedPath({ - mount: mounts[1], - containerPath: relative - ? path.posix.join(agentContainerRoot, relative) - : agentContainerRoot, - }); - } - - if (params.cwd) { - const cwdPosix = params.cwd.replace(/\\/g, "/"); - if (path.posix.isAbsolute(cwdPosix)) { - const cwdContainer = normalizeContainerPath(cwdPosix); - const cwdMount = this.resolveMountByContainerPath(mounts, cwdContainer); - if (cwdMount) { - return this.toResolvedPath({ - mount: cwdMount, - containerPath: normalizeContainerPath(path.posix.resolve(cwdContainer, inputPosix)), - }); - } - } - } - - throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${params.filePath}`); - } - - private toResolvedPath(params: { mount: MountInfo; containerPath: string }): ResolvedRemotePath { - const relative = path.posix.relative(params.mount.containerRoot, params.containerPath); - if (relative.startsWith("..") || path.posix.isAbsolute(relative)) { - throw new Error( - `Sandbox path escapes allowed mounts; cannot access: ${params.containerPath}`, - ); - } - return { - relativePath: - params.mount.source === "workspace" - ? relative === "." - ? "" - : relative - : relative === "." - ? params.mount.containerRoot - : `${params.mount.containerRoot}/${relative}`, - containerPath: params.containerPath, - writable: params.mount.writable, - mountRootPath: params.mount.containerRoot, - source: params.mount.source, - }; - } - - private resolveMountByContainerPath( - mounts: MountInfo[], - containerPath: string, - ): MountInfo | null { - const ordered = [...mounts].toSorted((a, b) => b.containerRoot.length - a.containerRoot.length); - for (const mount of ordered) { - if (isPathInsideContainerRoot(mount.containerRoot, containerPath)) { - return mount; - } - } - return null; - } - - private ensureWritable(target: ResolvedRemotePath, action: string) { - if (this.sandbox.workspaceAccess !== "rw" || !target.writable) { - throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`); - } - } - - private async remotePathExists(containerPath: string, signal?: AbortSignal): Promise { - const result = await this.runRemoteScript({ - script: 'if [ -e "$1" ] || [ -L "$1" ]; then printf "1\\n"; else printf "0\\n"; fi', - args: [containerPath], - signal, - }); - return result.stdout.toString("utf8").trim() === "1"; - } - - private async resolveCanonicalPath(params: { - containerPath: string; - action: string; - allowFinalSymlinkForUnlink?: boolean; - signal?: AbortSignal; - }): Promise { - const script = [ - "set -eu", - 'target="$1"', - 'allow_final="$2"', - 'suffix=""', - 'probe="$target"', - 'if [ "$allow_final" = "1" ] && [ -L "$target" ]; then probe=$(dirname -- "$target"); fi', - 'cursor="$probe"', - 'while [ ! -e "$cursor" ] && [ ! -L "$cursor" ]; do', - ' parent=$(dirname -- "$cursor")', - ' if [ "$parent" = "$cursor" ]; then break; fi', - ' base=$(basename -- "$cursor")', - ' suffix="/$base$suffix"', - ' cursor="$parent"', - "done", - 'canonical=$(readlink -f -- "$cursor")', - 'printf "%s%s\\n" "$canonical" "$suffix"', - ].join("\n"); - const result = await this.runRemoteScript({ - script, - args: [params.containerPath, params.allowFinalSymlinkForUnlink ? "1" : "0"], - signal: params.signal, - }); - const canonical = normalizeContainerPath(result.stdout.toString("utf8").trim()); - const mount = this.resolveMountByContainerPath( - [ - { - containerRoot: normalizeContainerPath(this.backend.remoteWorkspaceDir), - writable: this.sandbox.workspaceAccess === "rw", - source: "workspace", - }, - ...(this.sandbox.workspaceAccess !== "none" && - path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir) - ? [ - { - containerRoot: normalizeContainerPath(this.backend.remoteAgentWorkspaceDir), - writable: this.sandbox.workspaceAccess === "rw", - source: "agent" as const, - }, - ] - : []), - ], - canonical, - ); - if (!mount) { - throw new Error( - `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, - ); - } - return canonical; - } - - private async assertNoHardlinkedFile(params: { - containerPath: string; - action: string; - signal?: AbortSignal; - }): Promise { - const result = await this.runRemoteScript({ - script: [ - 'if [ ! -e "$1" ] && [ ! -L "$1" ]; then exit 0; fi', - 'stats=$(stat -c "%F|%h" -- "$1")', - 'printf "%s\\n" "$stats"', - ].join("\n"), - args: [params.containerPath], - signal: params.signal, - allowFailure: true, - }); - const output = result.stdout.toString("utf8").trim(); - if (!output) { - return; - } - const [kind = "", linksRaw = "1"] = output.split("|"); - if (kind === "regular file" && Number(linksRaw) > 1) { - throw new Error( - `Hardlinked path is not allowed under sandbox mount root: ${params.containerPath}`, - ); - } - } - - private async resolvePinnedParent(params: { - containerPath: string; - action: string; - requireWritable?: boolean; - allowFinalSymlinkForUnlink?: boolean; - }): Promise<{ mountRootPath: string; relativeParentPath: string; basename: string }> { - const basename = path.posix.basename(params.containerPath); - if (!basename || basename === "." || basename === "/") { - throw new Error(`Invalid sandbox entry target: ${params.containerPath}`); - } - const canonicalParent = await this.resolveCanonicalPath({ - containerPath: normalizeContainerPath(path.posix.dirname(params.containerPath)), - action: params.action, - allowFinalSymlinkForUnlink: params.allowFinalSymlinkForUnlink, - }); - const mount = this.resolveMountByContainerPath( - [ - { - containerRoot: normalizeContainerPath(this.backend.remoteWorkspaceDir), - writable: this.sandbox.workspaceAccess === "rw", - source: "workspace", - }, - ...(this.sandbox.workspaceAccess !== "none" && - path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir) - ? [ - { - containerRoot: normalizeContainerPath(this.backend.remoteAgentWorkspaceDir), - writable: this.sandbox.workspaceAccess === "rw", - source: "agent" as const, - }, - ] - : []), - ], - canonicalParent, - ); - if (!mount) { - throw new Error( - `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, - ); - } - if (params.requireWritable && !mount.writable) { - throw new Error( - `Sandbox path is read-only; cannot ${params.action}: ${params.containerPath}`, - ); - } - const relativeParentPath = path.posix.relative(mount.containerRoot, canonicalParent); - if (relativeParentPath.startsWith("..") || path.posix.isAbsolute(relativeParentPath)) { - throw new Error( - `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, - ); - } - return { - mountRootPath: mount.containerRoot, - relativeParentPath: relativeParentPath === "." ? "" : relativeParentPath, - basename, - }; - } - - private async runMutation(params: { - args: string[]; - stdin?: Buffer | string; - signal?: AbortSignal; - allowFailure?: boolean; - }) { - await this.runRemoteScript({ - script: [ - "set -eu", - "python3 /dev/fd/3 \"$@\" 3<<'PY'", - SANDBOX_PINNED_MUTATION_PYTHON, - "PY", - ].join("\n"), - args: params.args, - stdin: params.stdin, - signal: params.signal, - allowFailure: params.allowFailure, - }); - } - - private async runRemoteScript(params: { - script: string; - args?: string[]; - stdin?: Buffer | string; - signal?: AbortSignal; - allowFailure?: boolean; - }) { - return await this.backend.runRemoteShellScript({ - script: params.script, - args: params.args, - stdin: params.stdin, - signal: params.signal, - allowFailure: params.allowFailure, - }); - } -} - -function normalizeContainerPath(value: string): string { - const normalized = path.posix.normalize(value.trim() || "/"); - return normalized.startsWith("/") ? normalized : `/${normalized}`; -} - -function isPathInsideContainerRoot(root: string, candidate: string): boolean { - const normalizedRoot = normalizeContainerPath(root); - const normalizedCandidate = normalizeContainerPath(candidate); - return ( - normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`) - ); -} - -function isPathInside(root: string, candidate: string): boolean { - const relative = path.relative(root, candidate); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - -function toPosixRelative(root: string, candidate: string): string { - return path.relative(root, candidate).split(path.sep).filter(Boolean).join(path.posix.sep); + return createRemoteShellSandboxFsBridge({ + sandbox: params.sandbox, + runtime: params.backend, + }); } diff --git a/src/agents/sandbox-merge.test.ts b/src/agents/sandbox-merge.test.ts index d120ac84820..742701017d2 100644 --- a/src/agents/sandbox-merge.test.ts +++ b/src/agents/sandbox-merge.test.ts @@ -5,6 +5,7 @@ import { resolveSandboxDockerConfig, resolveSandboxPruneConfig, resolveSandboxScope, + resolveSandboxSshConfig, } from "./sandbox/config.js"; describe("sandbox config merges", () => { @@ -130,6 +131,41 @@ describe("sandbox config merges", () => { expect(pruneShared).toEqual({ idleHours: 24, maxAgeDays: 7 }); }); + it("merges sandbox ssh settings and ignores agent overrides under shared scope", () => { + const ssh = resolveSandboxSshConfig({ + scope: "agent", + globalSsh: { + target: "global@example.com:22", + command: "ssh", + identityFile: "~/.ssh/global", + strictHostKeyChecking: true, + }, + agentSsh: { + target: "agent@example.com:2222", + certificateFile: "~/.ssh/agent-cert.pub", + strictHostKeyChecking: false, + }, + }); + expect(ssh).toMatchObject({ + target: "agent@example.com:2222", + command: "ssh", + identityFile: "~/.ssh/global", + certificateFile: "~/.ssh/agent-cert.pub", + strictHostKeyChecking: false, + }); + + const sshShared = resolveSandboxSshConfig({ + scope: "shared", + globalSsh: { + target: "global@example.com:22", + }, + agentSsh: { + target: "agent@example.com:2222", + }, + }); + expect(sshShared.target).toBe("global@example.com:22"); + }); + it("defaults sandbox backend to docker", () => { expect(resolveSandboxConfigForAgent().backend).toBe("docker"); }); diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index b52cb5ab050..d26dc75204d 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -34,6 +34,18 @@ export { export { resolveSandboxToolPolicyForAgent } from "./sandbox/tool-policy.js"; export type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./sandbox/fs-bridge.js"; +export { + buildExecRemoteCommand, + buildRemoteCommand, + buildSshSandboxArgv, + createSshSandboxSessionFromConfigText, + createSshSandboxSessionFromSettings, + disposeSshSandboxSession, + runSshSandboxCommand, + shellEscape, + uploadDirectoryToSshTarget, +} from "./sandbox/ssh.js"; +export { createRemoteShellSandboxFsBridge } from "./sandbox/remote-fs-bridge.js"; export type { CreateSandboxBackendParams, @@ -47,6 +59,12 @@ export type { SandboxBackendRegistration, SandboxBackendRuntimeInfo, } from "./sandbox/backend.js"; +export type { RemoteShellSandboxHandle } from "./sandbox/remote-fs-bridge.js"; +export type { + RunSshSandboxCommandParams, + SshSandboxSession, + SshSandboxSettings, +} from "./sandbox/ssh.js"; export type { SandboxBrowserConfig, @@ -56,6 +74,7 @@ export type { SandboxDockerConfig, SandboxPruneConfig, SandboxScope, + SandboxSshConfig, SandboxToolPolicy, SandboxToolPolicyResolved, SandboxToolPolicySource, diff --git a/src/agents/sandbox/backend.ts b/src/agents/sandbox/backend.ts index c186b0fe4cc..013cb565176 100644 --- a/src/agents/sandbox/backend.ts +++ b/src/agents/sandbox/backend.ts @@ -65,7 +65,11 @@ export type SandboxBackendManager = { config: OpenClawConfig; agentId?: string; }): Promise; - removeRuntime(params: { entry: SandboxRegistryEntry }): Promise; + removeRuntime(params: { + entry: SandboxRegistryEntry; + config: OpenClawConfig; + agentId?: string; + }): Promise; }; export type CreateSandboxBackendParams = { @@ -141,8 +145,14 @@ export function requireSandboxBackendFactory(id: string): SandboxBackendFactory } import { createDockerSandboxBackend, dockerSandboxBackendManager } from "./docker-backend.js"; +import { createSshSandboxBackend, sshSandboxBackendManager } from "./ssh-backend.js"; registerSandboxBackend("docker", { factory: createDockerSandboxBackend, manager: dockerSandboxBackendManager, }); + +registerSandboxBackend("ssh", { + factory: createSshSandboxBackend, + manager: sshSandboxBackendManager, +}); diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index c62276c6b87..88b5feccccc 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -62,6 +62,12 @@ function buildConfig(enableNoVnc: boolean): SandboxConfig { capDrop: ["ALL"], env: { LANG: "C.UTF-8" }, }, + ssh: { + command: "ssh", + workspaceRoot: "/tmp/openclaw-sandboxes", + strictHostKeyChecking: true, + updateHostKeys: true, + }, browser: { enabled: true, image: "openclaw-sandbox-browser:bookworm-slim", diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts index dda3e048ea7..c5bd29e9d11 100644 --- a/src/agents/sandbox/config.ts +++ b/src/agents/sandbox/config.ts @@ -1,4 +1,6 @@ import type { OpenClawConfig } from "../../config/config.js"; +import type { SandboxSshSettings } from "../../config/types.sandbox.js"; +import { normalizeSecretInputString } from "../../config/types.secrets.js"; import { resolveAgentConfig } from "../agent-scope.js"; import { DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS, @@ -22,6 +24,7 @@ import type { SandboxDockerConfig, SandboxPruneConfig, SandboxScope, + SandboxSshConfig, } from "./types.js"; export const DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS = [ @@ -30,6 +33,9 @@ export const DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS = [ "dangerouslyAllowContainerNamespaceJoin", ] as const; +const DEFAULT_SANDBOX_SSH_COMMAND = "ssh"; +const DEFAULT_SANDBOX_SSH_WORKSPACE_ROOT = "/tmp/openclaw-sandboxes"; + type DangerousSandboxDockerBooleanKey = (typeof DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS)[number]; type DangerousSandboxDockerBooleans = Pick; @@ -167,6 +173,54 @@ export function resolveSandboxPruneConfig(params: { }; } +function normalizeOptionalString(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function normalizeRemoteRoot(value: string | undefined, fallback: string): string { + const normalized = normalizeOptionalString(value) ?? fallback; + const posix = normalized.replaceAll("\\", "/"); + if (!posix.startsWith("/")) { + throw new Error(`Sandbox SSH workspaceRoot must be an absolute POSIX path: ${normalized}`); + } + return posix.replace(/\/+$/g, "") || "/"; +} + +export function resolveSandboxSshConfig(params: { + scope: SandboxScope; + globalSsh?: Partial; + agentSsh?: Partial; +}): SandboxSshConfig { + const agentSsh = params.scope === "shared" ? undefined : params.agentSsh; + const globalSsh = params.globalSsh; + return { + target: normalizeOptionalString(agentSsh?.target ?? globalSsh?.target), + command: + normalizeOptionalString(agentSsh?.command ?? globalSsh?.command) ?? + DEFAULT_SANDBOX_SSH_COMMAND, + workspaceRoot: normalizeRemoteRoot( + agentSsh?.workspaceRoot ?? globalSsh?.workspaceRoot, + DEFAULT_SANDBOX_SSH_WORKSPACE_ROOT, + ), + strictHostKeyChecking: + agentSsh?.strictHostKeyChecking ?? globalSsh?.strictHostKeyChecking ?? true, + updateHostKeys: agentSsh?.updateHostKeys ?? globalSsh?.updateHostKeys ?? true, + identityFile: normalizeOptionalString(agentSsh?.identityFile ?? globalSsh?.identityFile), + certificateFile: normalizeOptionalString( + agentSsh?.certificateFile ?? globalSsh?.certificateFile, + ), + knownHostsFile: normalizeOptionalString(agentSsh?.knownHostsFile ?? globalSsh?.knownHostsFile), + identityData: normalizeSecretInputString(agentSsh?.identityData ?? globalSsh?.identityData), + certificateData: normalizeSecretInputString( + agentSsh?.certificateData ?? globalSsh?.certificateData, + ), + knownHostsData: normalizeSecretInputString( + agentSsh?.knownHostsData ?? globalSsh?.knownHostsData, + ), + }; +} + export function resolveSandboxConfigForAgent( cfg?: OpenClawConfig, agentId?: string, @@ -199,6 +253,11 @@ export function resolveSandboxConfigForAgent( globalDocker: agent?.docker, agentDocker: agentSandbox?.docker, }), + ssh: resolveSandboxSshConfig({ + scope, + globalSsh: agent?.ssh, + agentSsh: agentSandbox?.ssh, + }), browser: resolveSandboxBrowserConfig({ scope, globalBrowser: agent?.browser, diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index 54941ba04d1..46d37f9fd61 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -109,6 +109,12 @@ function createSandboxConfig( binds: binds ?? ["/tmp/workspace:/workspace:rw"], dangerouslyAllowReservedContainerTargets: true, }, + ssh: { + command: "ssh", + workspaceRoot: "/tmp/openclaw-sandboxes", + strictHostKeyChecking: true, + updateHostKeys: true, + }, browser: { enabled: false, image: "openclaw-browser:test", diff --git a/src/agents/sandbox/manage.ts b/src/agents/sandbox/manage.ts index 0b5ba578d7d..c6e6f3fd7bf 100644 --- a/src/agents/sandbox/manage.ts +++ b/src/agents/sandbox/manage.ts @@ -85,16 +85,22 @@ export async function listSandboxBrowsers(): Promise { } export async function removeSandboxContainer(containerName: string): Promise { + const config = loadConfig(); const registry = await readRegistry(); const entry = registry.entries.find((item) => item.containerName === containerName); if (entry) { const manager = getSandboxBackendManager(entry.backendId ?? "docker"); - await manager?.removeRuntime({ entry }); + await manager?.removeRuntime({ + entry, + config, + agentId: resolveSandboxAgentId(entry.sessionKey), + }); } await removeRegistryEntry(containerName); } export async function removeSandboxBrowserContainer(containerName: string): Promise { + const config = loadConfig(); const registry = await readBrowserRegistry(); const entry = registry.entries.find((item) => item.containerName === containerName); if (entry) { @@ -105,6 +111,7 @@ export async function removeSandboxBrowserContainer(containerName: string): Prom runtimeLabel: entry.containerName, configLabelKind: "Image", }, + config, }); } await removeBrowserRegistryEntry(containerName); diff --git a/src/agents/sandbox/prune.ts b/src/agents/sandbox/prune.ts index 6ccfd8ac238..8005c23330e 100644 --- a/src/agents/sandbox/prune.ts +++ b/src/agents/sandbox/prune.ts @@ -1,4 +1,5 @@ import { stopBrowserBridgeServer } from "../../browser/bridge-server.js"; +import { loadConfig } from "../../config/config.js"; import { defaultRuntime } from "../../runtime.js"; import { getSandboxBackendManager } from "./backend.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; @@ -62,18 +63,23 @@ async function pruneSandboxRegistryEntries( } async function pruneSandboxContainers(cfg: SandboxConfig) { + const config = loadConfig(); await pruneSandboxRegistryEntries({ cfg, read: readRegistry, remove: removeRegistryEntry, removeRuntime: async (entry) => { const manager = getSandboxBackendManager(entry.backendId ?? "docker"); - await manager?.removeRuntime({ entry }); + await manager?.removeRuntime({ + entry, + config, + }); }, }); } async function pruneSandboxBrowsers(cfg: SandboxConfig) { + const config = loadConfig(); await pruneSandboxRegistryEntries< SandboxBrowserRegistryEntry & { backendId?: string; @@ -92,6 +98,7 @@ async function pruneSandboxBrowsers(cfg: SandboxConfig) { runtimeLabel: entry.containerName, configLabelKind: "Image", }, + config, }); }, onRemoved: async (entry) => { diff --git a/src/agents/sandbox/remote-fs-bridge.ts b/src/agents/sandbox/remote-fs-bridge.ts new file mode 100644 index 00000000000..ef70e928eac --- /dev/null +++ b/src/agents/sandbox/remote-fs-bridge.ts @@ -0,0 +1,518 @@ +import path from "node:path"; +import type { SandboxBackendCommandParams, SandboxBackendCommandResult } from "./backend.js"; +import { SANDBOX_PINNED_MUTATION_PYTHON } from "./fs-bridge-mutation-helper.js"; +import type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./fs-bridge.js"; +import type { SandboxContext } from "./types.js"; + +type ResolvedRemotePath = SandboxResolvedPath & { + writable: boolean; + mountRootPath: string; + source: "workspace" | "agent"; +}; + +type MountInfo = { + containerRoot: string; + writable: boolean; + source: "workspace" | "agent"; +}; + +export type RemoteShellSandboxHandle = { + remoteWorkspaceDir: string; + remoteAgentWorkspaceDir: string; + runRemoteShellScript(params: SandboxBackendCommandParams): Promise; +}; + +export function createRemoteShellSandboxFsBridge(params: { + sandbox: SandboxContext; + runtime: RemoteShellSandboxHandle; +}): SandboxFsBridge { + return new RemoteShellSandboxFsBridge(params.sandbox, params.runtime); +} + +class RemoteShellSandboxFsBridge implements SandboxFsBridge { + constructor( + private readonly sandbox: SandboxContext, + private readonly runtime: RemoteShellSandboxHandle, + ) {} + + resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath { + const target = this.resolveTarget(params); + return { + relativePath: target.relativePath, + containerPath: target.containerPath, + }; + } + + async readFile(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + const canonical = await this.resolveCanonicalPath({ + containerPath: target.containerPath, + action: "read files", + signal: params.signal, + }); + await this.assertNoHardlinkedFile({ + containerPath: canonical, + action: "read files", + signal: params.signal, + }); + const result = await this.runRemoteScript({ + script: 'set -eu\ncat -- "$1"', + args: [canonical], + signal: params.signal, + }); + return result.stdout; + } + + async writeFile(params: { + filePath: string; + cwd?: string; + data: Buffer | string; + encoding?: BufferEncoding; + mkdir?: boolean; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "write files"); + const pinned = await this.resolvePinnedParent({ + containerPath: target.containerPath, + action: "write files", + requireWritable: true, + }); + await this.assertNoHardlinkedFile({ + containerPath: target.containerPath, + action: "write files", + signal: params.signal, + }); + const buffer = Buffer.isBuffer(params.data) + ? params.data + : Buffer.from(params.data, params.encoding ?? "utf8"); + await this.runMutation({ + args: [ + "write", + pinned.mountRootPath, + pinned.relativeParentPath, + pinned.basename, + params.mkdir !== false ? "1" : "0", + ], + stdin: buffer, + signal: params.signal, + }); + } + + async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "create directories"); + const relativePath = path.posix.relative(target.mountRootPath, target.containerPath); + if (relativePath.startsWith("..") || path.posix.isAbsolute(relativePath)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot create directories: ${target.containerPath}`, + ); + } + await this.runMutation({ + args: ["mkdirp", target.mountRootPath, relativePath === "." ? "" : relativePath], + signal: params.signal, + }); + } + + async remove(params: { + filePath: string; + cwd?: string; + recursive?: boolean; + force?: boolean; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "remove files"); + const exists = await this.remotePathExists(target.containerPath, params.signal); + if (!exists) { + if (params.force === false) { + throw new Error(`Sandbox path not found; cannot remove files: ${target.containerPath}`); + } + return; + } + const pinned = await this.resolvePinnedParent({ + containerPath: target.containerPath, + action: "remove files", + requireWritable: true, + allowFinalSymlinkForUnlink: true, + }); + await this.runMutation({ + args: [ + "remove", + pinned.mountRootPath, + pinned.relativeParentPath, + pinned.basename, + params.recursive ? "1" : "0", + params.force === false ? "0" : "1", + ], + signal: params.signal, + allowFailure: params.force !== false, + }); + } + + async rename(params: { + from: string; + to: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const from = this.resolveTarget({ filePath: params.from, cwd: params.cwd }); + const to = this.resolveTarget({ filePath: params.to, cwd: params.cwd }); + this.ensureWritable(from, "rename files"); + this.ensureWritable(to, "rename files"); + const fromPinned = await this.resolvePinnedParent({ + containerPath: from.containerPath, + action: "rename files", + requireWritable: true, + allowFinalSymlinkForUnlink: true, + }); + const toPinned = await this.resolvePinnedParent({ + containerPath: to.containerPath, + action: "rename files", + requireWritable: true, + }); + await this.runMutation({ + args: [ + "rename", + fromPinned.mountRootPath, + fromPinned.relativeParentPath, + fromPinned.basename, + toPinned.mountRootPath, + toPinned.relativeParentPath, + toPinned.basename, + "1", + ], + signal: params.signal, + }); + } + + async stat(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + const exists = await this.remotePathExists(target.containerPath, params.signal); + if (!exists) { + return null; + } + const canonical = await this.resolveCanonicalPath({ + containerPath: target.containerPath, + action: "stat files", + signal: params.signal, + }); + await this.assertNoHardlinkedFile({ + containerPath: canonical, + action: "stat files", + signal: params.signal, + }); + const result = await this.runRemoteScript({ + script: 'set -eu\nstat -c "%F|%s|%Y" -- "$1"', + args: [canonical], + signal: params.signal, + }); + const output = result.stdout.toString("utf8").trim(); + const [kindRaw = "", sizeRaw = "0", mtimeRaw = "0"] = output.split("|"); + return { + type: kindRaw === "directory" ? "directory" : kindRaw === "regular file" ? "file" : "other", + size: Number(sizeRaw), + mtimeMs: Number(mtimeRaw) * 1000, + }; + } + + private getMounts(): MountInfo[] { + const mounts: MountInfo[] = [ + { + containerRoot: normalizeContainerPath(this.runtime.remoteWorkspaceDir), + writable: this.sandbox.workspaceAccess === "rw", + source: "workspace", + }, + ]; + if ( + this.sandbox.workspaceAccess !== "none" && + path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir) + ) { + mounts.push({ + containerRoot: normalizeContainerPath(this.runtime.remoteAgentWorkspaceDir), + writable: this.sandbox.workspaceAccess === "rw", + source: "agent", + }); + } + return mounts; + } + + private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedRemotePath { + const workspaceRoot = path.resolve(this.sandbox.workspaceDir); + const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir); + const workspaceContainerRoot = normalizeContainerPath(this.runtime.remoteWorkspaceDir); + const agentContainerRoot = normalizeContainerPath(this.runtime.remoteAgentWorkspaceDir); + const mounts = this.getMounts(); + const input = params.filePath.trim(); + const inputPosix = input.replace(/\\/g, "/"); + const maybeContainerMount = path.posix.isAbsolute(inputPosix) + ? this.resolveMountByContainerPath(mounts, normalizeContainerPath(inputPosix)) + : null; + if (maybeContainerMount) { + return this.toResolvedPath({ + mount: maybeContainerMount, + containerPath: normalizeContainerPath(inputPosix), + }); + } + + const hostCwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot; + const hostCandidate = path.isAbsolute(input) + ? path.resolve(input) + : path.resolve(hostCwd, input); + if (isPathInside(workspaceRoot, hostCandidate)) { + const relative = toPosixRelative(workspaceRoot, hostCandidate); + return this.toResolvedPath({ + mount: mounts[0], + containerPath: relative + ? path.posix.join(workspaceContainerRoot, relative) + : workspaceContainerRoot, + }); + } + if (mounts[1] && isPathInside(agentRoot, hostCandidate)) { + const relative = toPosixRelative(agentRoot, hostCandidate); + return this.toResolvedPath({ + mount: mounts[1], + containerPath: relative + ? path.posix.join(agentContainerRoot, relative) + : agentContainerRoot, + }); + } + + if (params.cwd) { + const cwdPosix = params.cwd.replace(/\\/g, "/"); + if (path.posix.isAbsolute(cwdPosix)) { + const cwdContainer = normalizeContainerPath(cwdPosix); + const cwdMount = this.resolveMountByContainerPath(mounts, cwdContainer); + if (cwdMount) { + return this.toResolvedPath({ + mount: cwdMount, + containerPath: normalizeContainerPath(path.posix.resolve(cwdContainer, inputPosix)), + }); + } + } + } + + throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${params.filePath}`); + } + + private toResolvedPath(params: { mount: MountInfo; containerPath: string }): ResolvedRemotePath { + const relative = path.posix.relative(params.mount.containerRoot, params.containerPath); + if (relative.startsWith("..") || path.posix.isAbsolute(relative)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot access: ${params.containerPath}`, + ); + } + return { + relativePath: + params.mount.source === "workspace" + ? relative === "." + ? "" + : relative + : relative === "." + ? params.mount.containerRoot + : `${params.mount.containerRoot}/${relative}`, + containerPath: params.containerPath, + writable: params.mount.writable, + mountRootPath: params.mount.containerRoot, + source: params.mount.source, + }; + } + + private resolveMountByContainerPath( + mounts: MountInfo[], + containerPath: string, + ): MountInfo | null { + const ordered = [...mounts].toSorted((a, b) => b.containerRoot.length - a.containerRoot.length); + for (const mount of ordered) { + if (isPathInsideContainerRoot(mount.containerRoot, containerPath)) { + return mount; + } + } + return null; + } + + private ensureWritable(target: ResolvedRemotePath, action: string) { + if (this.sandbox.workspaceAccess !== "rw" || !target.writable) { + throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`); + } + } + + private async remotePathExists(containerPath: string, signal?: AbortSignal): Promise { + const result = await this.runRemoteScript({ + script: 'if [ -e "$1" ] || [ -L "$1" ]; then printf "1\\n"; else printf "0\\n"; fi', + args: [containerPath], + signal, + }); + return result.stdout.toString("utf8").trim() === "1"; + } + + private async resolveCanonicalPath(params: { + containerPath: string; + action: string; + allowFinalSymlinkForUnlink?: boolean; + signal?: AbortSignal; + }): Promise { + const script = [ + "set -eu", + 'target="$1"', + 'allow_final="$2"', + 'suffix=""', + 'probe="$target"', + 'if [ "$allow_final" = "1" ] && [ -L "$target" ]; then probe=$(dirname -- "$target"); fi', + 'cursor="$probe"', + 'while [ ! -e "$cursor" ] && [ ! -L "$cursor" ]; do', + ' parent=$(dirname -- "$cursor")', + ' if [ "$parent" = "$cursor" ]; then break; fi', + ' base=$(basename -- "$cursor")', + ' suffix="/$base$suffix"', + ' cursor="$parent"', + "done", + 'canonical=$(readlink -f -- "$cursor")', + 'printf "%s%s\\n" "$canonical" "$suffix"', + ].join("\n"); + const result = await this.runRemoteScript({ + script, + args: [params.containerPath, params.allowFinalSymlinkForUnlink ? "1" : "0"], + signal: params.signal, + }); + const canonical = normalizeContainerPath(result.stdout.toString("utf8").trim()); + if (!this.resolveMountByContainerPath(this.getMounts(), canonical)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, + ); + } + return canonical; + } + + private async assertNoHardlinkedFile(params: { + containerPath: string; + action: string; + signal?: AbortSignal; + }): Promise { + const result = await this.runRemoteScript({ + script: [ + 'if [ ! -e "$1" ] && [ ! -L "$1" ]; then exit 0; fi', + 'stats=$(stat -c "%F|%h" -- "$1")', + 'printf "%s\\n" "$stats"', + ].join("\n"), + args: [params.containerPath], + signal: params.signal, + allowFailure: true, + }); + const output = result.stdout.toString("utf8").trim(); + if (!output) { + return; + } + const [kind = "", linksRaw = "1"] = output.split("|"); + if (kind === "regular file" && Number(linksRaw) > 1) { + throw new Error( + `Hardlinked path is not allowed under sandbox mount root: ${params.containerPath}`, + ); + } + } + + private async resolvePinnedParent(params: { + containerPath: string; + action: string; + requireWritable?: boolean; + allowFinalSymlinkForUnlink?: boolean; + }): Promise<{ mountRootPath: string; relativeParentPath: string; basename: string }> { + const basename = path.posix.basename(params.containerPath); + if (!basename || basename === "." || basename === "/") { + throw new Error(`Invalid sandbox entry target: ${params.containerPath}`); + } + const canonicalParent = await this.resolveCanonicalPath({ + containerPath: normalizeContainerPath(path.posix.dirname(params.containerPath)), + action: params.action, + allowFinalSymlinkForUnlink: params.allowFinalSymlinkForUnlink, + }); + const mount = this.resolveMountByContainerPath(this.getMounts(), canonicalParent); + if (!mount) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, + ); + } + if (params.requireWritable && !mount.writable) { + throw new Error( + `Sandbox path is read-only; cannot ${params.action}: ${params.containerPath}`, + ); + } + const relativeParentPath = path.posix.relative(mount.containerRoot, canonicalParent); + if (relativeParentPath.startsWith("..") || path.posix.isAbsolute(relativeParentPath)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, + ); + } + return { + mountRootPath: mount.containerRoot, + relativeParentPath: relativeParentPath === "." ? "" : relativeParentPath, + basename, + }; + } + + private async runMutation(params: { + args: string[]; + stdin?: Buffer | string; + signal?: AbortSignal; + allowFailure?: boolean; + }) { + await this.runRemoteScript({ + script: [ + "set -eu", + "python3 /dev/fd/3 \"$@\" 3<<'PY'", + SANDBOX_PINNED_MUTATION_PYTHON, + "PY", + ].join("\n"), + args: params.args, + stdin: params.stdin, + signal: params.signal, + allowFailure: params.allowFailure, + }); + } + + private async runRemoteScript(params: { + script: string; + args?: string[]; + stdin?: Buffer | string; + signal?: AbortSignal; + allowFailure?: boolean; + }) { + return await this.runtime.runRemoteShellScript({ + script: params.script, + args: params.args, + stdin: params.stdin, + signal: params.signal, + allowFailure: params.allowFailure, + }); + } +} + +function normalizeContainerPath(value: string): string { + const normalized = path.posix.normalize(value.trim() || "/"); + return normalized.startsWith("/") ? normalized : `/${normalized}`; +} + +function isPathInsideContainerRoot(root: string, candidate: string): boolean { + const normalizedRoot = normalizeContainerPath(root); + const normalizedCandidate = normalizeContainerPath(candidate); + return ( + normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`) + ); +} + +function isPathInside(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function toPosixRelative(root: string, candidate: string): string { + return path.relative(root, candidate).split(path.sep).filter(Boolean).join(path.posix.sep); +} diff --git a/src/agents/sandbox/ssh-backend.ts b/src/agents/sandbox/ssh-backend.ts new file mode 100644 index 00000000000..f241103fc19 --- /dev/null +++ b/src/agents/sandbox/ssh-backend.ts @@ -0,0 +1,303 @@ +import path from "node:path"; +import type { + CreateSandboxBackendParams, + SandboxBackendCommandParams, + SandboxBackendCommandResult, + SandboxBackendHandle, + SandboxBackendManager, +} from "./backend.js"; +import { resolveSandboxConfigForAgent } from "./config.js"; +import { + createRemoteShellSandboxFsBridge, + type RemoteShellSandboxHandle, +} from "./remote-fs-bridge.js"; +import { + buildExecRemoteCommand, + buildRemoteCommand, + buildSshSandboxArgv, + createSshSandboxSessionFromSettings, + disposeSshSandboxSession, + runSshSandboxCommand, + uploadDirectoryToSshTarget, + type SshSandboxSession, +} from "./ssh.js"; + +type PendingExec = { + sshSession: SshSandboxSession; +}; + +type ResolvedSshRuntimePaths = { + runtimeId: string; + runtimeRootDir: string; + remoteWorkspaceDir: string; + remoteAgentWorkspaceDir: string; +}; + +export const sshSandboxBackendManager: SandboxBackendManager = { + async describeRuntime({ entry, config, agentId }) { + const cfg = resolveSandboxConfigForAgent(config, agentId); + if (cfg.backend !== "ssh" || !cfg.ssh.target) { + return { + running: false, + actualConfigLabel: cfg.ssh.target, + configLabelMatch: false, + }; + } + const runtimePaths = resolveSshRuntimePaths(cfg.ssh.workspaceRoot, entry.sessionKey); + const session = await createSshSandboxSessionFromSettings({ + ...cfg.ssh, + target: cfg.ssh.target, + }); + try { + const result = await runSshSandboxCommand({ + session, + remoteCommand: buildRemoteCommand([ + "/bin/sh", + "-c", + 'if [ -d "$1" ]; then printf "1\\n"; else printf "0\\n"; fi', + "openclaw-sandbox-check", + runtimePaths.runtimeRootDir, + ]), + }); + return { + running: result.stdout.toString("utf8").trim() === "1", + actualConfigLabel: cfg.ssh.target, + configLabelMatch: entry.image === cfg.ssh.target, + }; + } finally { + await disposeSshSandboxSession(session); + } + }, + async removeRuntime({ entry, config, agentId }) { + const cfg = resolveSandboxConfigForAgent(config, agentId); + if (cfg.backend !== "ssh" || !cfg.ssh.target) { + return; + } + const runtimePaths = resolveSshRuntimePaths(cfg.ssh.workspaceRoot, entry.sessionKey); + const session = await createSshSandboxSessionFromSettings({ + ...cfg.ssh, + target: cfg.ssh.target, + }); + try { + await runSshSandboxCommand({ + session, + remoteCommand: buildRemoteCommand([ + "/bin/sh", + "-c", + 'rm -rf -- "$1"', + "openclaw-sandbox-remove", + runtimePaths.runtimeRootDir, + ]), + allowFailure: true, + }); + } finally { + await disposeSshSandboxSession(session); + } + }, +}; + +export async function createSshSandboxBackend( + params: CreateSandboxBackendParams, +): Promise { + if ((params.cfg.docker.binds?.length ?? 0) > 0) { + throw new Error("SSH sandbox backend does not support sandbox.docker.binds."); + } + const target = params.cfg.ssh.target; + if (!target) { + throw new Error('Sandbox backend "ssh" requires agents.defaults.sandbox.ssh.target.'); + } + + const runtimePaths = resolveSshRuntimePaths(params.cfg.ssh.workspaceRoot, params.scopeKey); + const impl = new SshSandboxBackendImpl({ + createParams: params, + target, + runtimePaths, + }); + return impl.asHandle(); +} + +class SshSandboxBackendImpl { + private ensurePromise: Promise | null = null; + + constructor( + private readonly params: { + createParams: CreateSandboxBackendParams; + target: string; + runtimePaths: ResolvedSshRuntimePaths; + }, + ) {} + + asHandle(): SandboxBackendHandle & RemoteShellSandboxHandle { + return { + id: "ssh", + runtimeId: this.params.runtimePaths.runtimeId, + runtimeLabel: this.params.runtimePaths.runtimeId, + workdir: this.params.runtimePaths.remoteWorkspaceDir, + env: this.params.createParams.cfg.docker.env, + configLabel: this.params.target, + configLabelKind: "Target", + remoteWorkspaceDir: this.params.runtimePaths.remoteWorkspaceDir, + remoteAgentWorkspaceDir: this.params.runtimePaths.remoteAgentWorkspaceDir, + buildExecSpec: async ({ command, workdir, env, usePty }) => { + await this.ensureRuntime(); + const sshSession = await this.createSession(); + const remoteCommand = buildExecRemoteCommand({ + command, + workdir: workdir ?? this.params.runtimePaths.remoteWorkspaceDir, + env, + }); + return { + argv: buildSshSandboxArgv({ + session: sshSession, + remoteCommand, + tty: usePty, + }), + env: process.env, + stdinMode: "pipe-open", + finalizeToken: { sshSession } satisfies PendingExec, + }; + }, + finalizeExec: async ({ token }) => { + const sshSession = (token as PendingExec | undefined)?.sshSession; + if (sshSession) { + await disposeSshSandboxSession(sshSession); + } + }, + runShellCommand: async (command) => await this.runRemoteShellScript(command), + createFsBridge: ({ sandbox }) => + createRemoteShellSandboxFsBridge({ + sandbox, + runtime: this.asHandle(), + }), + runRemoteShellScript: async (command) => await this.runRemoteShellScript(command), + }; + } + + private async createSession(): Promise { + return await createSshSandboxSessionFromSettings({ + ...this.params.createParams.cfg.ssh, + target: this.params.target, + }); + } + + private async ensureRuntime(): Promise { + if (this.ensurePromise) { + return await this.ensurePromise; + } + this.ensurePromise = this.ensureRuntimeInner(); + try { + await this.ensurePromise; + } catch (error) { + this.ensurePromise = null; + throw error; + } + } + + private async ensureRuntimeInner(): Promise { + const session = await this.createSession(); + try { + const exists = await runSshSandboxCommand({ + session, + remoteCommand: buildRemoteCommand([ + "/bin/sh", + "-c", + 'if [ -d "$1" ]; then printf "1\\n"; else printf "0\\n"; fi', + "openclaw-sandbox-check", + this.params.runtimePaths.runtimeRootDir, + ]), + }); + if (exists.stdout.toString("utf8").trim() === "1") { + return; + } + await this.replaceRemoteDirectoryFromLocal( + session, + this.params.createParams.workspaceDir, + this.params.runtimePaths.remoteWorkspaceDir, + ); + if ( + this.params.createParams.cfg.workspaceAccess !== "none" && + path.resolve(this.params.createParams.agentWorkspaceDir) !== + path.resolve(this.params.createParams.workspaceDir) + ) { + await this.replaceRemoteDirectoryFromLocal( + session, + this.params.createParams.agentWorkspaceDir, + this.params.runtimePaths.remoteAgentWorkspaceDir, + ); + } + } finally { + await disposeSshSandboxSession(session); + } + } + + private async replaceRemoteDirectoryFromLocal( + session: SshSandboxSession, + localDir: string, + remoteDir: string, + ): Promise { + await runSshSandboxCommand({ + session, + remoteCommand: buildRemoteCommand([ + "/bin/sh", + "-c", + 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +', + "openclaw-sandbox-clear", + remoteDir, + ]), + }); + await uploadDirectoryToSshTarget({ + session, + localDir, + remoteDir, + }); + } + + async runRemoteShellScript( + params: SandboxBackendCommandParams, + ): Promise { + await this.ensureRuntime(); + const session = await this.createSession(); + try { + return await runSshSandboxCommand({ + session, + remoteCommand: buildRemoteCommand([ + "/bin/sh", + "-c", + params.script, + "openclaw-sandbox-fs", + ...(params.args ?? []), + ]), + stdin: params.stdin, + allowFailure: params.allowFailure, + signal: params.signal, + }); + } finally { + await disposeSshSandboxSession(session); + } + } +} + +function resolveSshRuntimePaths(workspaceRoot: string, scopeKey: string): ResolvedSshRuntimePaths { + const runtimeId = buildSshSandboxRuntimeId(scopeKey); + const runtimeRootDir = path.posix.join(workspaceRoot, runtimeId); + return { + runtimeId, + runtimeRootDir, + remoteWorkspaceDir: path.posix.join(runtimeRootDir, "workspace"), + remoteAgentWorkspaceDir: path.posix.join(runtimeRootDir, "agent"), + }; +} + +function buildSshSandboxRuntimeId(scopeKey: string): string { + const trimmed = scopeKey.trim() || "session"; + const safe = trimmed + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 32); + const hash = Array.from(trimmed).reduce( + (acc, char) => ((acc * 33) ^ char.charCodeAt(0)) >>> 0, + 5381, + ); + return `openclaw-ssh-${safe || "session"}-${hash.toString(16).slice(0, 8)}`; +} diff --git a/src/agents/sandbox/ssh.test.ts b/src/agents/sandbox/ssh.test.ts new file mode 100644 index 00000000000..c2c07a3bf11 --- /dev/null +++ b/src/agents/sandbox/ssh.test.ts @@ -0,0 +1,61 @@ +import fs from "node:fs/promises"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildExecRemoteCommand, + createSshSandboxSessionFromSettings, + disposeSshSandboxSession, + type SshSandboxSession, +} from "./ssh.js"; + +const sessions: SshSandboxSession[] = []; + +afterEach(async () => { + await Promise.all( + sessions.splice(0).map(async (session) => { + await disposeSshSandboxSession(session); + }), + ); +}); + +describe("sandbox ssh helpers", () => { + it("materializes inline ssh auth data into a temp config", async () => { + const session = await createSshSandboxSessionFromSettings({ + command: "ssh", + target: "peter@example.com:2222", + strictHostKeyChecking: true, + updateHostKeys: false, + identityData: "PRIVATE KEY", + certificateData: "SSH CERT", + knownHostsData: "example.com ssh-ed25519 AAAATEST", + }); + sessions.push(session); + + const config = await fs.readFile(session.configPath, "utf8"); + expect(config).toContain("Host openclaw-sandbox"); + expect(config).toContain("HostName example.com"); + expect(config).toContain("User peter"); + expect(config).toContain("Port 2222"); + expect(config).toContain("StrictHostKeyChecking yes"); + expect(config).toContain("UpdateHostKeys no"); + + const configDir = session.configPath.slice(0, session.configPath.lastIndexOf("/")); + expect(await fs.readFile(`${configDir}/identity`, "utf8")).toBe("PRIVATE KEY"); + expect(await fs.readFile(`${configDir}/certificate.pub`, "utf8")).toBe("SSH CERT"); + expect(await fs.readFile(`${configDir}/known_hosts`, "utf8")).toBe( + "example.com ssh-ed25519 AAAATEST", + ); + }); + + it("wraps remote exec commands with env and workdir", () => { + const command = buildExecRemoteCommand({ + command: "pwd && printenv TOKEN", + workdir: "/sandbox/project", + env: { + TOKEN: "abc 123", + }, + }); + expect(command).toContain(`'env'`); + expect(command).toContain(`'TOKEN=abc 123'`); + expect(command).toContain(`'cd '"'"'/sandbox/project'"'"' && pwd && printenv TOKEN'`); + }); +}); diff --git a/src/agents/sandbox/ssh.ts b/src/agents/sandbox/ssh.ts new file mode 100644 index 00000000000..1590b515e8f --- /dev/null +++ b/src/agents/sandbox/ssh.ts @@ -0,0 +1,334 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { parseSshTarget } from "../../infra/ssh-tunnel.js"; +import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js"; +import { resolveUserPath } from "../../utils.js"; +import type { SandboxBackendCommandResult } from "./backend.js"; + +export type SshSandboxSettings = { + command: string; + target: string; + strictHostKeyChecking: boolean; + updateHostKeys: boolean; + identityFile?: string; + certificateFile?: string; + knownHostsFile?: string; + identityData?: string; + certificateData?: string; + knownHostsData?: string; +}; + +export type SshSandboxSession = { + command: string; + configPath: string; + host: string; +}; + +export type RunSshSandboxCommandParams = { + session: SshSandboxSession; + remoteCommand: string; + stdin?: Buffer | string; + allowFailure?: boolean; + signal?: AbortSignal; + tty?: boolean; +}; + +export function shellEscape(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +export function buildRemoteCommand(argv: string[]): string { + return argv.map((entry) => shellEscape(entry)).join(" "); +} + +export function buildExecRemoteCommand(params: { + command: string; + workdir?: string; + env: Record; +}): string { + const body = params.workdir + ? `cd ${shellEscape(params.workdir)} && ${params.command}` + : params.command; + const argv = + Object.keys(params.env).length > 0 + ? [ + "env", + ...Object.entries(params.env).map(([key, value]) => `${key}=${value}`), + "/bin/sh", + "-c", + body, + ] + : ["/bin/sh", "-c", body]; + return buildRemoteCommand(argv); +} + +export function buildSshSandboxArgv(params: { + session: SshSandboxSession; + remoteCommand: string; + tty?: boolean; +}): string[] { + return [ + params.session.command, + "-F", + params.session.configPath, + ...(params.tty + ? ["-tt", "-o", "RequestTTY=force", "-o", "SetEnv=TERM=xterm-256color"] + : ["-T", "-o", "RequestTTY=no"]), + params.session.host, + params.remoteCommand, + ]; +} + +export async function createSshSandboxSessionFromConfigText(params: { + configText: string; + host?: string; + command?: string; +}): Promise { + const host = params.host?.trim() || parseSshConfigHost(params.configText); + if (!host) { + throw new Error("Failed to parse SSH config output."); + } + const configDir = await fs.mkdtemp(path.join(resolveSshTmpRoot(), "openclaw-sandbox-ssh-")); + const configPath = path.join(configDir, "config"); + await fs.writeFile(configPath, params.configText, { encoding: "utf8", mode: 0o600 }); + await fs.chmod(configPath, 0o600); + return { + command: params.command?.trim() || "ssh", + configPath, + host, + }; +} + +export async function createSshSandboxSessionFromSettings( + settings: SshSandboxSettings, +): Promise { + const parsed = parseSshTarget(settings.target); + if (!parsed) { + throw new Error(`Invalid sandbox SSH target: ${settings.target}`); + } + + const configDir = await fs.mkdtemp(path.join(resolveSshTmpRoot(), "openclaw-sandbox-ssh-")); + try { + const materializedIdentity = settings.identityData + ? await writeSecretMaterial(configDir, "identity", settings.identityData) + : undefined; + const materializedCertificate = settings.certificateData + ? await writeSecretMaterial(configDir, "certificate.pub", settings.certificateData) + : undefined; + const materializedKnownHosts = settings.knownHostsData + ? await writeSecretMaterial(configDir, "known_hosts", settings.knownHostsData) + : undefined; + const identityFile = materializedIdentity ?? resolveOptionalLocalPath(settings.identityFile); + const certificateFile = + materializedCertificate ?? resolveOptionalLocalPath(settings.certificateFile); + const knownHostsFile = + materializedKnownHosts ?? resolveOptionalLocalPath(settings.knownHostsFile); + const hostAlias = "openclaw-sandbox"; + const configPath = path.join(configDir, "config"); + const lines = [ + `Host ${hostAlias}`, + ` HostName ${parsed.host}`, + ` Port ${parsed.port}`, + " BatchMode yes", + " ConnectTimeout 5", + " ServerAliveInterval 15", + " ServerAliveCountMax 3", + ` StrictHostKeyChecking ${settings.strictHostKeyChecking ? "yes" : "no"}`, + ` UpdateHostKeys ${settings.updateHostKeys ? "yes" : "no"}`, + ]; + if (parsed.user) { + lines.push(` User ${parsed.user}`); + } + if (knownHostsFile) { + lines.push(` UserKnownHostsFile ${knownHostsFile}`); + } else if (!settings.strictHostKeyChecking) { + lines.push(" UserKnownHostsFile /dev/null"); + } + if (identityFile) { + lines.push(` IdentityFile ${identityFile}`); + } + if (certificateFile) { + lines.push(` CertificateFile ${certificateFile}`); + } + if (identityFile || certificateFile) { + lines.push(" IdentitiesOnly yes"); + } + await fs.writeFile(configPath, `${lines.join("\n")}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await fs.chmod(configPath, 0o600); + return { + command: settings.command.trim() || "ssh", + configPath, + host: hostAlias, + }; + } catch (error) { + await fs.rm(configDir, { recursive: true, force: true }); + throw error; + } +} + +export async function disposeSshSandboxSession(session: SshSandboxSession): Promise { + await fs.rm(path.dirname(session.configPath), { recursive: true, force: true }); +} + +export async function runSshSandboxCommand( + params: RunSshSandboxCommandParams, +): Promise { + const argv = buildSshSandboxArgv({ + session: params.session, + remoteCommand: params.remoteCommand, + tty: params.tty, + }); + return await new Promise((resolve, reject) => { + const child = spawn(argv[0], argv.slice(1), { + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + signal: params.signal, + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); + child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); + child.on("error", reject); + child.on("close", (code) => { + const stdout = Buffer.concat(stdoutChunks); + const stderr = Buffer.concat(stderrChunks); + const exitCode = code ?? 0; + if (exitCode !== 0 && !params.allowFailure) { + reject( + Object.assign( + new Error(stderr.toString("utf8").trim() || `ssh exited with code ${exitCode}`), + { + code: exitCode, + stdout, + stderr, + }, + ), + ); + return; + } + resolve({ stdout, stderr, code: exitCode }); + }); + + if (params.stdin !== undefined) { + child.stdin.end(params.stdin); + return; + } + child.stdin.end(); + }); +} + +export async function uploadDirectoryToSshTarget(params: { + session: SshSandboxSession; + localDir: string; + remoteDir: string; + signal?: AbortSignal; +}): Promise { + const remoteCommand = buildRemoteCommand([ + "/bin/sh", + "-c", + 'mkdir -p -- "$1" && tar -xf - -C "$1"', + "openclaw-sandbox-upload", + params.remoteDir, + ]); + const sshArgv = buildSshSandboxArgv({ + session: params.session, + remoteCommand, + }); + await new Promise((resolve, reject) => { + const tar = spawn("tar", ["-C", params.localDir, "-cf", "-", "."], { + stdio: ["ignore", "pipe", "pipe"], + signal: params.signal, + }); + const ssh = spawn(sshArgv[0], sshArgv.slice(1), { + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + signal: params.signal, + }); + const tarStderr: Buffer[] = []; + const sshStdout: Buffer[] = []; + const sshStderr: Buffer[] = []; + let tarClosed = false; + let sshClosed = false; + let tarCode = 0; + let sshCode = 0; + + tar.stderr.on("data", (chunk) => tarStderr.push(Buffer.from(chunk))); + ssh.stdout.on("data", (chunk) => sshStdout.push(Buffer.from(chunk))); + ssh.stderr.on("data", (chunk) => sshStderr.push(Buffer.from(chunk))); + + const fail = (error: unknown) => { + tar.kill("SIGKILL"); + ssh.kill("SIGKILL"); + reject(error); + }; + + tar.on("error", fail); + ssh.on("error", fail); + tar.stdout.pipe(ssh.stdin); + + tar.on("close", (code) => { + tarClosed = true; + tarCode = code ?? 0; + maybeResolve(); + }); + ssh.on("close", (code) => { + sshClosed = true; + sshCode = code ?? 0; + maybeResolve(); + }); + + function maybeResolve() { + if (!tarClosed || !sshClosed) { + return; + } + if (tarCode !== 0) { + reject( + new Error( + Buffer.concat(tarStderr).toString("utf8").trim() || `tar exited with code ${tarCode}`, + ), + ); + return; + } + if (sshCode !== 0) { + reject( + new Error( + Buffer.concat(sshStderr).toString("utf8").trim() || `ssh exited with code ${sshCode}`, + ), + ); + return; + } + resolve(); + } + }); +} + +function parseSshConfigHost(configText: string): string | null { + const hostMatch = configText.match(/^\s*Host\s+(\S+)/m); + return hostMatch?.[1]?.trim() || null; +} + +function resolveSshTmpRoot(): string { + return path.resolve(resolvePreferredOpenClawTmpDir() ?? os.tmpdir()); +} + +function resolveOptionalLocalPath(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? resolveUserPath(trimmed) : undefined; +} + +async function writeSecretMaterial( + dir: string, + filename: string, + contents: string, +): Promise { + const pathname = path.join(dir, filename); + await fs.writeFile(pathname, contents, { encoding: "utf8", mode: 0o600 }); + await fs.chmod(pathname, 0o600); + return pathname; +} diff --git a/src/agents/sandbox/types.ts b/src/agents/sandbox/types.ts index 8244583ea0c..482ce6a922e 100644 --- a/src/agents/sandbox/types.ts +++ b/src/agents/sandbox/types.ts @@ -51,6 +51,20 @@ export type SandboxPruneConfig = { maxAgeDays: number; }; +export type SandboxSshConfig = { + target?: string; + command: string; + workspaceRoot: string; + strictHostKeyChecking: boolean; + updateHostKeys: boolean; + identityFile?: string; + certificateFile?: string; + knownHostsFile?: string; + identityData?: string; + certificateData?: string; + knownHostsData?: string; +}; + export type SandboxScope = "session" | "agent" | "shared"; export type SandboxConfig = { @@ -60,6 +74,7 @@ export type SandboxConfig = { workspaceAccess: SandboxWorkspaceAccess; workspaceRoot: string; docker: SandboxDockerConfig; + ssh: SandboxSshConfig; browser: SandboxBrowserConfig; tools: SandboxToolPolicy; prune: SandboxPruneConfig; diff --git a/src/config/types.agents-shared.ts b/src/config/types.agents-shared.ts index 1e398cc1c70..3351d9903c9 100644 --- a/src/config/types.agents-shared.ts +++ b/src/config/types.agents-shared.ts @@ -2,6 +2,7 @@ import type { SandboxBrowserSettings, SandboxDockerSettings, SandboxPruneSettings, + SandboxSshSettings, } from "./types.sandbox.js"; export type AgentModelConfig = @@ -32,6 +33,8 @@ export type AgentSandboxConfig = { workspaceRoot?: string; /** Docker-specific sandbox settings. */ docker?: SandboxDockerSettings; + /** SSH-specific sandbox settings. */ + ssh?: SandboxSshSettings; /** Optional sandboxed browser settings. */ browser?: SandboxBrowserSettings; /** Auto-prune sandbox settings. */ diff --git a/src/config/types.sandbox.ts b/src/config/types.sandbox.ts index 047f10cde53..04128e2ffaa 100644 --- a/src/config/types.sandbox.ts +++ b/src/config/types.sandbox.ts @@ -1,3 +1,5 @@ +import type { SecretInput } from "./types.secrets.js"; + export type SandboxDockerSettings = { /** Docker image to use for sandbox containers. */ image?: string; @@ -94,3 +96,28 @@ export type SandboxPruneSettings = { /** Prune if older than N days (0 disables). */ maxAgeDays?: number; }; + +export type SandboxSshSettings = { + /** SSH target in user@host[:port] form. */ + target?: string; + /** SSH client command. Default: "ssh". */ + command?: string; + /** Absolute remote root used for per-scope workspaces. */ + workspaceRoot?: string; + /** Enforce host-key verification. Default: true. */ + strictHostKeyChecking?: boolean; + /** Allow OpenSSH host-key updates. Default: true. */ + updateHostKeys?: boolean; + /** Existing private key path on the host. */ + identityFile?: string; + /** Existing SSH certificate path on the host. */ + certificateFile?: string; + /** Existing known_hosts file path on the host. */ + knownHostsFile?: string; + /** Inline or SecretRef-backed private key contents. */ + identityData?: SecretInput; + /** Inline or SecretRef-backed SSH certificate contents. */ + certificateData?: SecretInput; + /** Inline or SecretRef-backed known_hosts contents. */ + knownHostsData?: SecretInput; +}; diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 9ddbedf929e..10cef396275 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -501,6 +501,23 @@ const ToolLoopDetectionSchema = z }) .optional(); +export const SandboxSshSchema = z + .object({ + target: z.string().min(1).optional(), + command: z.string().min(1).optional(), + workspaceRoot: z.string().min(1).optional(), + strictHostKeyChecking: z.boolean().optional(), + updateHostKeys: z.boolean().optional(), + identityFile: z.string().min(1).optional(), + certificateFile: z.string().min(1).optional(), + knownHostsFile: z.string().min(1).optional(), + identityData: SecretInputSchema.optional().register(sensitive), + certificateData: SecretInputSchema.optional().register(sensitive), + knownHostsData: SecretInputSchema.optional().register(sensitive), + }) + .strict() + .optional(); + export const AgentSandboxSchema = z .object({ mode: z.union([z.literal("off"), z.literal("non-main"), z.literal("all")]).optional(), @@ -511,6 +528,7 @@ export const AgentSandboxSchema = z perSession: z.boolean().optional(), workspaceRoot: z.string().optional(), docker: SandboxDockerSchema, + ssh: SandboxSshSchema, browser: SandboxBrowserSchema, prune: SandboxPruneSchema, }) diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index f3a6d1ca16b..025efaff67a 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -31,6 +31,8 @@ export type { } from "../plugins/types.js"; export type { CreateSandboxBackendParams, + RemoteShellSandboxHandle, + RunSshSandboxCommandParams, SandboxBackendCommandParams, SandboxBackendCommandResult, SandboxBackendExecSpec, @@ -44,6 +46,9 @@ export type { SandboxBackendRuntimeInfo, SandboxContext, SandboxResolvedPath, + SandboxSshConfig, + SshSandboxSession, + SshSandboxSettings, } from "../agents/sandbox.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; @@ -57,9 +62,19 @@ export type { export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { + buildExecRemoteCommand, + buildRemoteCommand, + buildSshSandboxArgv, + createRemoteShellSandboxFsBridge, + createSshSandboxSessionFromConfigText, + createSshSandboxSessionFromSettings, + disposeSshSandboxSession, getSandboxBackendFactory, getSandboxBackendManager, registerSandboxBackend, + runSshSandboxCommand, + shellEscape, + uploadDirectoryToSshTarget, requireSandboxBackendFactory, } from "../agents/sandbox.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; diff --git a/src/secrets/runtime-config-collectors-core.ts b/src/secrets/runtime-config-collectors-core.ts index 99668371ad1..ef571b3f54f 100644 --- a/src/secrets/runtime-config-collectors-core.ts +++ b/src/secrets/runtime-config-collectors-core.ts @@ -313,6 +313,90 @@ function collectCronAssignments(params: { }); } +function collectSandboxSshAssignments(params: { + config: OpenClawConfig; + defaults: SecretDefaults | undefined; + context: ResolverContext; +}): void { + const agents = isRecord(params.config.agents) ? params.config.agents : undefined; + if (!agents) { + return; + } + const defaultsAgent = isRecord(agents.defaults) ? agents.defaults : undefined; + const defaultsSandbox = isRecord(defaultsAgent?.sandbox) ? defaultsAgent.sandbox : undefined; + const defaultsSsh = isRecord(defaultsSandbox?.ssh) + ? (defaultsSandbox.ssh as Record) + : undefined; + const defaultsBackend = + typeof defaultsSandbox?.backend === "string" ? defaultsSandbox.backend : undefined; + const defaultsMode = typeof defaultsSandbox?.mode === "string" ? defaultsSandbox.mode : undefined; + + const inheritedDefaultsUsage = { + identityData: false, + certificateData: false, + knownHostsData: false, + }; + + const list = Array.isArray(agents.list) ? agents.list : []; + list.forEach((rawAgent, index) => { + const agentRecord = isRecord(rawAgent) ? (rawAgent as Record) : null; + if (!agentRecord || agentRecord.enabled === false) { + return; + } + const sandbox = isRecord(agentRecord.sandbox) ? agentRecord.sandbox : undefined; + const ssh = isRecord(sandbox?.ssh) ? sandbox.ssh : undefined; + const effectiveBackend = + (typeof sandbox?.backend === "string" ? sandbox.backend : undefined) ?? + defaultsBackend ?? + "docker"; + const effectiveMode = + (typeof sandbox?.mode === "string" ? sandbox.mode : undefined) ?? defaultsMode ?? "off"; + const active = effectiveBackend.trim().toLowerCase() === "ssh" && effectiveMode !== "off"; + for (const key of ["identityData", "certificateData", "knownHostsData"] as const) { + if (ssh && Object.prototype.hasOwnProperty.call(ssh, key)) { + collectSecretInputAssignment({ + value: ssh[key], + path: `agents.list.${index}.sandbox.ssh.${key}`, + expected: "string", + defaults: params.defaults, + context: params.context, + active, + inactiveReason: "sandbox SSH backend is not active for this agent.", + apply: (value) => { + ssh[key] = value; + }, + }); + } else if (active) { + inheritedDefaultsUsage[key] = true; + } + } + }); + + if (!defaultsSsh) { + return; + } + + const defaultsActive = + (defaultsBackend?.trim().toLowerCase() === "ssh" && defaultsMode !== "off") || + inheritedDefaultsUsage.identityData || + inheritedDefaultsUsage.certificateData || + inheritedDefaultsUsage.knownHostsData; + for (const key of ["identityData", "certificateData", "knownHostsData"] as const) { + collectSecretInputAssignment({ + value: defaultsSsh[key], + path: `agents.defaults.sandbox.ssh.${key}`, + expected: "string", + defaults: params.defaults, + context: params.context, + active: defaultsActive || inheritedDefaultsUsage[key], + inactiveReason: "sandbox SSH backend is not active.", + apply: (value) => { + defaultsSsh[key] = value; + }, + }); + } +} + export function collectCoreConfigAssignments(params: { config: OpenClawConfig; defaults: SecretDefaults | undefined; @@ -339,6 +423,7 @@ export function collectCoreConfigAssignments(params: { collectAgentMemorySearchAssignments(params); collectTalkAssignments(params); collectGatewayAssignments(params); + collectSandboxSshAssignments(params); collectMessagesTtsAssignments(params); collectCronAssignments(params); } diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 47628f1bfe2..837a174efaa 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -221,6 +221,46 @@ describe("secrets runtime snapshot", () => { ).toEqual({ source: "env", provider: "default", id: "OPENAI_API_KEY" }); }); + it("resolves sandbox ssh secret refs for active ssh backends", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "ssh", + ssh: { + target: "peter@example.com:22", + identityData: { source: "env", provider: "default", id: "SSH_IDENTITY_DATA" }, + certificateData: { + source: "env", + provider: "default", + id: "SSH_CERTIFICATE_DATA", + }, + knownHostsData: { + source: "env", + provider: "default", + id: "SSH_KNOWN_HOSTS_DATA", + }, + }, + }, + }, + }, + }), + env: { + SSH_IDENTITY_DATA: "PRIVATE KEY", + SSH_CERTIFICATE_DATA: "SSH CERT", + SSH_KNOWN_HOSTS_DATA: "example.com ssh-ed25519 AAAATEST", + }, + }); + + expect(snapshot.config.agents?.defaults?.sandbox?.ssh).toMatchObject({ + identityData: "PRIVATE KEY", + certificateData: "SSH CERT", + knownHostsData: "example.com ssh-ed25519 AAAATEST", + }); + }); + it("normalizes inline SecretRef object on token to tokenRef", async () => { const config: OpenClawConfig = { models: {}, secrets: {} }; const snapshot = await prepareSecretsRuntimeSnapshot({ From 0a2f95916be6354d6e898ec3b8eb45015e16f16a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:38:22 -0700 Subject: [PATCH 188/943] test: expand ssh sandbox coverage and docs --- docs/cli/sandbox.md | 6 + docs/gateway/configuration-reference.md | 7 + docs/gateway/sandboxing.md | 12 + docs/gateway/secrets.md | 29 ++ src/agents/sandbox/ssh-backend.test.ts | 338 ++++++++++++++++++++++++ src/secrets/runtime.test.ts | 33 +++ 6 files changed, 425 insertions(+) create mode 100644 src/agents/sandbox/ssh-backend.test.ts diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index f320be3b771..5764851dc70 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -19,6 +19,12 @@ Today that usually means: - SSH sandbox runtimes when `agents.defaults.sandbox.backend = "ssh"` - OpenShell sandbox runtimes when `agents.defaults.sandbox.backend = "openshell"` +For `ssh` and OpenShell `remote`, recreate matters more than with Docker: + +- the remote workspace is canonical after the initial seed +- `openclaw sandbox recreate` deletes that canonical remote workspace for the selected scope +- next use seeds it again from the current local workspace + ## Commands ### `openclaw sandbox explain` diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index ecefd8bbc4e..0653fd3834f 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1232,6 +1232,13 @@ When `backend: "openshell"` is selected, runtime-specific settings move to - `identityData` / `certificateData` / `knownHostsData`: inline contents or SecretRefs that OpenClaw materializes into temp files at runtime - `strictHostKeyChecking` / `updateHostKeys`: OpenSSH host-key policy knobs +**SSH auth precedence:** + +- `identityData` wins over `identityFile` +- `certificateData` wins over `certificateFile` +- `knownHostsData` wins over `knownHostsFile` +- SecretRef-backed `*Data` values are resolved from the active secrets runtime snapshot before the sandbox session starts + **SSH backend behavior:** - seeds the remote workspace once after create or recreate diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index b37757334c0..c6cf839e42d 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -105,6 +105,12 @@ How it works: - After that, `exec`, `read`, `write`, `edit`, `apply_patch`, prompt media reads, and inbound media staging run directly against the remote workspace over SSH. - OpenClaw does not sync remote changes back to the local workspace automatically. +Authentication material: + +- `identityFile`, `certificateFile`, `knownHostsFile`: use existing local files and pass them through OpenSSH config. +- `identityData`, `certificateData`, `knownHostsData`: use inline strings or SecretRefs. OpenClaw resolves them through the normal secrets runtime snapshot, writes them to temp files with `0600`, and deletes them when the SSH session ends. +- If both `*File` and `*Data` are set for the same item, `*Data` wins for that SSH session. + This is a **remote-canonical** model. The remote SSH workspace becomes the real sandbox state after the initial seed. Important consequences: @@ -150,6 +156,12 @@ OpenShell modes: OpenShell reuses the same core SSH transport and remote filesystem bridge as the generic SSH backend. The plugin adds OpenShell-specific lifecycle (`sandbox create/get/delete`, `sandbox ssh-config`) and the optional `mirror` mode. +Remote transport details: + +- OpenClaw asks OpenShell for sandbox-specific SSH config via `openshell sandbox ssh-config `. +- Core writes that SSH config to a temp file, opens the SSH session, and reuses the same remote filesystem bridge used by `backend: "ssh"`. +- In `mirror` mode only the lifecycle differs: sync local to remote before exec, then sync back after exec. + Current OpenShell limitations: - sandbox browser is not supported yet diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index eb044eaf03c..05554b1f6d3 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -288,6 +288,35 @@ Optional per-id errors: } ``` +## Sandbox SSH auth material + +The core `ssh` sandbox backend also supports SecretRefs for SSH auth material: + +```json5 +{ + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "ssh", + ssh: { + target: "user@gateway-host:22", + identityData: { source: "env", provider: "default", id: "SSH_IDENTITY" }, + certificateData: { source: "env", provider: "default", id: "SSH_CERTIFICATE" }, + knownHostsData: { source: "env", provider: "default", id: "SSH_KNOWN_HOSTS" }, + }, + }, + }, + }, +} +``` + +Runtime behavior: + +- OpenClaw resolves these refs during sandbox activation, not lazily during each SSH call. +- Resolved values are written to temp files with restrictive permissions and used in generated SSH config. +- If the effective sandbox backend is not `ssh`, these refs stay inactive and do not block startup. + ## Supported credential surface Canonical supported and unsupported credentials are listed in: diff --git a/src/agents/sandbox/ssh-backend.test.ts b/src/agents/sandbox/ssh-backend.test.ts new file mode 100644 index 00000000000..c8ec3b5f750 --- /dev/null +++ b/src/agents/sandbox/ssh-backend.test.ts @@ -0,0 +1,338 @@ +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; + +const sshMocks = vi.hoisted(() => ({ + createSshSandboxSessionFromSettings: vi.fn(), + disposeSshSandboxSession: vi.fn(), + runSshSandboxCommand: vi.fn(), + uploadDirectoryToSshTarget: vi.fn(), + buildSshSandboxArgv: vi.fn(), +})); + +vi.mock("./ssh.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createSshSandboxSessionFromSettings: sshMocks.createSshSandboxSessionFromSettings, + disposeSshSandboxSession: sshMocks.disposeSshSandboxSession, + runSshSandboxCommand: sshMocks.runSshSandboxCommand, + uploadDirectoryToSshTarget: sshMocks.uploadDirectoryToSshTarget, + buildSshSandboxArgv: sshMocks.buildSshSandboxArgv, + }; +}); + +import { createSshSandboxBackend, sshSandboxBackendManager } from "./ssh-backend.js"; + +function createConfig(): OpenClawConfig { + return { + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "ssh", + scope: "session", + workspaceAccess: "rw", + ssh: { + target: "peter@example.com:2222", + command: "ssh", + workspaceRoot: "/remote/openclaw", + strictHostKeyChecking: true, + updateHostKeys: true, + }, + }, + }, + }, + }; +} + +function createSession() { + return { + command: "ssh", + configPath: path.join(os.tmpdir(), "openclaw-test-ssh-config"), + host: "openclaw-sandbox", + }; +} + +describe("ssh sandbox backend", () => { + beforeEach(() => { + vi.clearAllMocks(); + sshMocks.createSshSandboxSessionFromSettings.mockResolvedValue(createSession()); + sshMocks.disposeSshSandboxSession.mockResolvedValue(undefined); + sshMocks.runSshSandboxCommand.mockResolvedValue({ + stdout: Buffer.from("1\n"), + stderr: Buffer.alloc(0), + code: 0, + }); + sshMocks.uploadDirectoryToSshTarget.mockResolvedValue(undefined); + sshMocks.buildSshSandboxArgv.mockImplementation(({ session, remoteCommand, tty }) => [ + session.command, + "-F", + session.configPath, + tty ? "-tt" : "-T", + session.host, + remoteCommand, + ]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("describes runtimes via the configured ssh target", async () => { + const result = await sshSandboxBackendManager.describeRuntime({ + entry: { + containerName: "openclaw-ssh-worker-abcd1234", + backendId: "ssh", + runtimeLabel: "openclaw-ssh-worker-abcd1234", + sessionKey: "agent:worker", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "peter@example.com:2222", + configLabelKind: "Target", + }, + config: createConfig(), + }); + + expect(result).toEqual({ + running: true, + actualConfigLabel: "peter@example.com:2222", + configLabelMatch: true, + }); + expect(sshMocks.createSshSandboxSessionFromSettings).toHaveBeenCalledWith( + expect.objectContaining({ + target: "peter@example.com:2222", + workspaceRoot: "/remote/openclaw", + }), + ); + expect(sshMocks.runSshSandboxCommand).toHaveBeenCalledWith( + expect.objectContaining({ + remoteCommand: expect.stringContaining("/remote/openclaw/openclaw-ssh-agent-worker"), + }), + ); + }); + + it("removes runtimes by deleting the remote scope root", async () => { + await sshSandboxBackendManager.removeRuntime({ + entry: { + containerName: "openclaw-ssh-worker-abcd1234", + backendId: "ssh", + runtimeLabel: "openclaw-ssh-worker-abcd1234", + sessionKey: "agent:worker", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "peter@example.com:2222", + configLabelKind: "Target", + }, + config: createConfig(), + }); + + expect(sshMocks.runSshSandboxCommand).toHaveBeenCalledWith( + expect.objectContaining({ + allowFailure: true, + remoteCommand: expect.stringContaining('rm -rf -- "$1"'), + }), + ); + }); + + it("creates a remote-canonical backend that seeds once and reuses ssh exec", async () => { + sshMocks.runSshSandboxCommand + .mockResolvedValueOnce({ + stdout: Buffer.from("0\n"), + stderr: Buffer.alloc(0), + code: 0, + }) + .mockResolvedValueOnce({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }) + .mockResolvedValueOnce({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }); + + const backend = await createSshSandboxBackend({ + sessionKey: "agent:worker:task", + scopeKey: "agent:worker", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/agent", + cfg: { + mode: "all", + backend: "ssh", + scope: "session", + workspaceAccess: "rw", + workspaceRoot: "~/.openclaw/sandboxes", + docker: { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp"], + network: "none", + capDrop: ["ALL"], + env: { LANG: "C.UTF-8" }, + }, + ssh: { + target: "peter@example.com:2222", + command: "ssh", + workspaceRoot: "/remote/openclaw", + strictHostKeyChecking: true, + updateHostKeys: true, + }, + browser: { + enabled: false, + image: "openclaw-browser", + containerPrefix: "openclaw-browser-", + network: "bridge", + cdpPort: 9222, + vncPort: 5900, + noVncPort: 6080, + headless: true, + enableNoVnc: false, + allowHostControl: false, + autoStart: false, + autoStartTimeoutMs: 1000, + }, + tools: { allow: [], deny: [] }, + prune: { idleHours: 24, maxAgeDays: 7 }, + }, + }); + + const execSpec = await backend.buildExecSpec({ + command: "pwd", + env: { TEST_TOKEN: "1" }, + usePty: false, + }); + + expect(execSpec.argv).toEqual( + expect.arrayContaining(["ssh", "-F", createSession().configPath, "-T", createSession().host]), + ); + expect(execSpec.argv.at(-1)).toContain("/remote/openclaw/openclaw-ssh-agent-worker"); + expect(sshMocks.uploadDirectoryToSshTarget).toHaveBeenCalledTimes(2); + expect(sshMocks.uploadDirectoryToSshTarget).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + localDir: "/tmp/workspace", + remoteDir: expect.stringContaining("/workspace"), + }), + ); + expect(sshMocks.uploadDirectoryToSshTarget).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + localDir: "/tmp/agent", + remoteDir: expect.stringContaining("/agent"), + }), + ); + + await backend.finalizeExec?.({ + status: "completed", + exitCode: 0, + timedOut: false, + token: execSpec.finalizeToken, + }); + expect(sshMocks.disposeSshSandboxSession).toHaveBeenCalled(); + }); + + it("rejects docker binds and missing ssh target", async () => { + await expect( + createSshSandboxBackend({ + sessionKey: "s", + scopeKey: "s", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + cfg: { + mode: "all", + backend: "ssh", + scope: "session", + workspaceAccess: "rw", + workspaceRoot: "~/.openclaw/sandboxes", + docker: { + image: "img", + containerPrefix: "prefix-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp"], + network: "none", + capDrop: ["ALL"], + env: {}, + binds: ["/tmp:/tmp:rw"], + }, + ssh: { + target: "peter@example.com:22", + command: "ssh", + workspaceRoot: "/remote/openclaw", + strictHostKeyChecking: true, + updateHostKeys: true, + }, + browser: { + enabled: false, + image: "img", + containerPrefix: "prefix-", + network: "bridge", + cdpPort: 1, + vncPort: 2, + noVncPort: 3, + headless: true, + enableNoVnc: false, + allowHostControl: false, + autoStart: false, + autoStartTimeoutMs: 1, + }, + tools: { allow: [], deny: [] }, + prune: { idleHours: 24, maxAgeDays: 7 }, + }, + }), + ).rejects.toThrow("does not support sandbox.docker.binds"); + + await expect( + createSshSandboxBackend({ + sessionKey: "s", + scopeKey: "s", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + cfg: { + mode: "all", + backend: "ssh", + scope: "session", + workspaceAccess: "rw", + workspaceRoot: "~/.openclaw/sandboxes", + docker: { + image: "img", + containerPrefix: "prefix-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp"], + network: "none", + capDrop: ["ALL"], + env: {}, + }, + ssh: { + command: "ssh", + workspaceRoot: "/remote/openclaw", + strictHostKeyChecking: true, + updateHostKeys: true, + }, + browser: { + enabled: false, + image: "img", + containerPrefix: "prefix-", + network: "bridge", + cdpPort: 1, + vncPort: 2, + noVncPort: 3, + headless: true, + enableNoVnc: false, + allowHostControl: false, + autoStart: false, + autoStartTimeoutMs: 1, + }, + tools: { allow: [], deny: [] }, + prune: { idleHours: 24, maxAgeDays: 7 }, + }, + }), + ).rejects.toThrow("requires agents.defaults.sandbox.ssh.target"); + }); +}); diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 837a174efaa..8e7e549ae51 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -261,6 +261,39 @@ describe("secrets runtime snapshot", () => { }); }); + it("treats sandbox ssh secret refs as inactive when ssh backend is not selected", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "docker", + ssh: { + identityData: { source: "env", provider: "default", id: "SSH_IDENTITY_DATA" }, + }, + }, + }, + }, + }), + env: {}, + }); + + expect(snapshot.config.agents?.defaults?.sandbox?.ssh?.identityData).toEqual({ + source: "env", + provider: "default", + id: "SSH_IDENTITY_DATA", + }); + expect(snapshot.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", + path: "agents.defaults.sandbox.ssh.identityData", + }), + ]), + ); + }); + it("normalizes inline SecretRef object on token to tokenRef", async () => { const config: OpenClawConfig = { models: {}, secrets: {} }; const snapshot = await prepareSecretsRuntimeSnapshot({ From 1beea52d8dfd8c9248a24fa5bc982d78e4d7396a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:37:19 -0700 Subject: [PATCH 189/943] refactor: rename setup wizard surfaces --- src/canvas-host/a2ui/a2ui.bundle.js | 15272 ++++++++++++++++++++++++++ 1 file changed, 15272 insertions(+) create mode 100644 src/canvas-host/a2ui/a2ui.bundle.js diff --git a/src/canvas-host/a2ui/a2ui.bundle.js b/src/canvas-host/a2ui/a2ui.bundle.js new file mode 100644 index 00000000000..d12450da71f --- /dev/null +++ b/src/canvas-host/a2ui/a2ui.bundle.js @@ -0,0 +1,15272 @@ +var __defProp$1 = Object.defineProperty; +var __exportAll = (all, no_symbols) => { + let target = {}; + for (var name in all) + __defProp$1(target, name, { + get: all[name], + enumerable: true, + }); + if (!no_symbols) __defProp$1(target, Symbol.toStringTag, { value: "Module" }); + return target; +}; +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const t$6 = globalThis, + e$13 = + t$6.ShadowRoot && + (void 0 === t$6.ShadyCSS || t$6.ShadyCSS.nativeShadow) && + "adoptedStyleSheets" in Document.prototype && + "replace" in CSSStyleSheet.prototype, + s$8 = Symbol(), + o$14 = /* @__PURE__ */ new WeakMap(); +var n$12 = class { + constructor(t, e, o) { + if (((this._$cssResult$ = !0), o !== s$8)) + throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); + ((this.cssText = t), (this.t = e)); + } + get styleSheet() { + let t = this.o; + const s = this.t; + if (e$13 && void 0 === t) { + const e = void 0 !== s && 1 === s.length; + (e && (t = o$14.get(s)), + void 0 === t && + ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), e && o$14.set(s, t))); + } + return t; + } + toString() { + return this.cssText; + } +}; +const r$11 = (t) => new n$12("string" == typeof t ? t : t + "", void 0, s$8), + i$9 = (t, ...e) => { + return new n$12( + 1 === t.length + ? t[0] + : e.reduce( + (e, s, o) => + e + + ((t) => { + if (!0 === t._$cssResult$) return t.cssText; + if ("number" == typeof t) return t; + throw Error( + "Value passed to 'css' function must be a 'css' function result: " + + t + + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.", + ); + })(s) + + t[o + 1], + t[0], + ), + t, + s$8, + ); + }, + S$1 = (s, o) => { + if (e$13) s.adoptedStyleSheets = o.map((t) => (t instanceof CSSStyleSheet ? t : t.styleSheet)); + else + for (const e of o) { + const o = document.createElement("style"), + n = t$6.litNonce; + (void 0 !== n && o.setAttribute("nonce", n), (o.textContent = e.cssText), s.appendChild(o)); + } + }, + c$6 = e$13 + ? (t) => t + : (t) => + t instanceof CSSStyleSheet + ? ((t) => { + let e = ""; + for (const s of t.cssRules) e += s.cssText; + return r$11(e); + })(t) + : t; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ const { + is: i$8, + defineProperty: e$12, + getOwnPropertyDescriptor: h$6, + getOwnPropertyNames: r$10, + getOwnPropertySymbols: o$13, + getPrototypeOf: n$11, + } = Object, + a$1 = globalThis, + c$5 = a$1.trustedTypes, + l$4 = c$5 ? c$5.emptyScript : "", + p$2 = a$1.reactiveElementPolyfillSupport, + d$2 = (t, s) => t, + u$3 = { + toAttribute(t, s) { + switch (s) { + case Boolean: + t = t ? l$4 : null; + break; + case Object: + case Array: + t = null == t ? t : JSON.stringify(t); + } + return t; + }, + fromAttribute(t, s) { + let i = t; + switch (s) { + case Boolean: + i = null !== t; + break; + case Number: + i = null === t ? null : Number(t); + break; + case Object: + case Array: + try { + i = JSON.parse(t); + } catch (t) { + i = null; + } + } + return i; + }, + }, + f$3 = (t, s) => !i$8(t, s), + b$1 = { + attribute: !0, + type: String, + converter: u$3, + reflect: !1, + useDefault: !1, + hasChanged: f$3, + }; +((Symbol.metadata ??= Symbol("metadata")), + (a$1.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap())); +var y$1 = class extends HTMLElement { + static addInitializer(t) { + (this._$Ei(), (this.l ??= []).push(t)); + } + static get observedAttributes() { + return (this.finalize(), this._$Eh && [...this._$Eh.keys()]); + } + static createProperty(t, s = b$1) { + if ( + (s.state && (s.attribute = !1), + this._$Ei(), + this.prototype.hasOwnProperty(t) && ((s = Object.create(s)).wrapped = !0), + this.elementProperties.set(t, s), + !s.noAccessor) + ) { + const i = Symbol(), + h = this.getPropertyDescriptor(t, i, s); + void 0 !== h && e$12(this.prototype, t, h); + } + } + static getPropertyDescriptor(t, s, i) { + const { get: e, set: r } = h$6(this.prototype, t) ?? { + get() { + return this[s]; + }, + set(t) { + this[s] = t; + }, + }; + return { + get: e, + set(s) { + const h = e?.call(this); + (r?.call(this, s), this.requestUpdate(t, h, i)); + }, + configurable: !0, + enumerable: !0, + }; + } + static getPropertyOptions(t) { + return this.elementProperties.get(t) ?? b$1; + } + static _$Ei() { + if (this.hasOwnProperty(d$2("elementProperties"))) return; + const t = n$11(this); + (t.finalize(), + void 0 !== t.l && (this.l = [...t.l]), + (this.elementProperties = new Map(t.elementProperties))); + } + static finalize() { + if (this.hasOwnProperty(d$2("finalized"))) return; + if (((this.finalized = !0), this._$Ei(), this.hasOwnProperty(d$2("properties")))) { + const t = this.properties, + s = [...r$10(t), ...o$13(t)]; + for (const i of s) this.createProperty(i, t[i]); + } + const t = this[Symbol.metadata]; + if (null !== t) { + const s = litPropertyMetadata.get(t); + if (void 0 !== s) for (const [t, i] of s) this.elementProperties.set(t, i); + } + this._$Eh = /* @__PURE__ */ new Map(); + for (const [t, s] of this.elementProperties) { + const i = this._$Eu(t, s); + void 0 !== i && this._$Eh.set(i, t); + } + this.elementStyles = this.finalizeStyles(this.styles); + } + static finalizeStyles(s) { + const i = []; + if (Array.isArray(s)) { + const e = new Set(s.flat(Infinity).reverse()); + for (const s of e) i.unshift(c$6(s)); + } else void 0 !== s && i.push(c$6(s)); + return i; + } + static _$Eu(t, s) { + const i = s.attribute; + return !1 === i + ? void 0 + : "string" == typeof i + ? i + : "string" == typeof t + ? t.toLowerCase() + : void 0; + } + constructor() { + (super(), + (this._$Ep = void 0), + (this.isUpdatePending = !1), + (this.hasUpdated = !1), + (this._$Em = null), + this._$Ev()); + } + _$Ev() { + ((this._$ES = new Promise((t) => (this.enableUpdating = t))), + (this._$AL = /* @__PURE__ */ new Map()), + this._$E_(), + this.requestUpdate(), + this.constructor.l?.forEach((t) => t(this))); + } + addController(t) { + ((this._$EO ??= /* @__PURE__ */ new Set()).add(t), + void 0 !== this.renderRoot && this.isConnected && t.hostConnected?.()); + } + removeController(t) { + this._$EO?.delete(t); + } + _$E_() { + const t = /* @__PURE__ */ new Map(), + s = this.constructor.elementProperties; + for (const i of s.keys()) this.hasOwnProperty(i) && (t.set(i, this[i]), delete this[i]); + t.size > 0 && (this._$Ep = t); + } + createRenderRoot() { + const t = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); + return (S$1(t, this.constructor.elementStyles), t); + } + connectedCallback() { + ((this.renderRoot ??= this.createRenderRoot()), + this.enableUpdating(!0), + this._$EO?.forEach((t) => t.hostConnected?.())); + } + enableUpdating(t) {} + disconnectedCallback() { + this._$EO?.forEach((t) => t.hostDisconnected?.()); + } + attributeChangedCallback(t, s, i) { + this._$AK(t, i); + } + _$ET(t, s) { + const i = this.constructor.elementProperties.get(t), + e = this.constructor._$Eu(t, i); + if (void 0 !== e && !0 === i.reflect) { + const h = (void 0 !== i.converter?.toAttribute ? i.converter : u$3).toAttribute(s, i.type); + ((this._$Em = t), + null == h ? this.removeAttribute(e) : this.setAttribute(e, h), + (this._$Em = null)); + } + } + _$AK(t, s) { + const i = this.constructor, + e = i._$Eh.get(t); + if (void 0 !== e && this._$Em !== e) { + const t = i.getPropertyOptions(e), + h = + "function" == typeof t.converter + ? { fromAttribute: t.converter } + : void 0 !== t.converter?.fromAttribute + ? t.converter + : u$3; + this._$Em = e; + const r = h.fromAttribute(s, t.type); + ((this[e] = r ?? this._$Ej?.get(e) ?? r), (this._$Em = null)); + } + } + requestUpdate(t, s, i, e = !1, h) { + if (void 0 !== t) { + const r = this.constructor; + if ( + (!1 === e && (h = this[t]), + (i ??= r.getPropertyOptions(t)), + !( + (i.hasChanged ?? f$3)(h, s) || + (i.useDefault && i.reflect && h === this._$Ej?.get(t) && !this.hasAttribute(r._$Eu(t, i))) + )) + ) + return; + this.C(t, s, i); + } + !1 === this.isUpdatePending && (this._$ES = this._$EP()); + } + C(t, s, { useDefault: i, reflect: e, wrapped: h }, r) { + (i && + !(this._$Ej ??= /* @__PURE__ */ new Map()).has(t) && + (this._$Ej.set(t, r ?? s ?? this[t]), !0 !== h || void 0 !== r)) || + (this._$AL.has(t) || (this.hasUpdated || i || (s = void 0), this._$AL.set(t, s)), + !0 === e && this._$Em !== t && (this._$Eq ??= /* @__PURE__ */ new Set()).add(t)); + } + async _$EP() { + this.isUpdatePending = !0; + try { + await this._$ES; + } catch (t) { + Promise.reject(t); + } + const t = this.scheduleUpdate(); + return (null != t && (await t), !this.isUpdatePending); + } + scheduleUpdate() { + return this.performUpdate(); + } + performUpdate() { + if (!this.isUpdatePending) return; + if (!this.hasUpdated) { + if (((this.renderRoot ??= this.createRenderRoot()), this._$Ep)) { + for (const [t, s] of this._$Ep) this[t] = s; + this._$Ep = void 0; + } + const t = this.constructor.elementProperties; + if (t.size > 0) + for (const [s, i] of t) { + const { wrapped: t } = i, + e = this[s]; + !0 !== t || this._$AL.has(s) || void 0 === e || this.C(s, void 0, i, e); + } + } + let t = !1; + const s = this._$AL; + try { + ((t = this.shouldUpdate(s)), + t + ? (this.willUpdate(s), this._$EO?.forEach((t) => t.hostUpdate?.()), this.update(s)) + : this._$EM()); + } catch (s) { + throw ((t = !1), this._$EM(), s); + } + t && this._$AE(s); + } + willUpdate(t) {} + _$AE(t) { + (this._$EO?.forEach((t) => t.hostUpdated?.()), + this.hasUpdated || ((this.hasUpdated = !0), this.firstUpdated(t)), + this.updated(t)); + } + _$EM() { + ((this._$AL = /* @__PURE__ */ new Map()), (this.isUpdatePending = !1)); + } + get updateComplete() { + return this.getUpdateComplete(); + } + getUpdateComplete() { + return this._$ES; + } + shouldUpdate(t) { + return !0; + } + update(t) { + ((this._$Eq &&= this._$Eq.forEach((t) => this._$ET(t, this[t]))), this._$EM()); + } + updated(t) {} + firstUpdated(t) {} +}; +((y$1.elementStyles = []), + (y$1.shadowRootOptions = { mode: "open" }), + (y$1[d$2("elementProperties")] = /* @__PURE__ */ new Map()), + (y$1[d$2("finalized")] = /* @__PURE__ */ new Map()), + p$2?.({ ReactiveElement: y$1 }), + (a$1.reactiveElementVersions ??= []).push("2.1.2")); +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const t$5 = globalThis, + i$7 = (t) => t, + s$7 = t$5.trustedTypes, + e$11 = s$7 ? s$7.createPolicy("lit-html", { createHTML: (t) => t }) : void 0, + h$5 = "$lit$", + o$12 = `lit$${Math.random().toFixed(9).slice(2)}$`, + n$10 = "?" + o$12, + r$9 = `<${n$10}>`, + l$3 = document, + c$4 = () => l$3.createComment(""), + a = (t) => null === t || ("object" != typeof t && "function" != typeof t), + u$2 = Array.isArray, + d$1 = (t) => u$2(t) || "function" == typeof t?.[Symbol.iterator], + f$2 = "[ \n\f\r]", + v$1 = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, + _ = /-->/g, + m$2 = />/g, + p$1 = RegExp(`>|${f$2}(?:([^\\s"'>=/]+)(${f$2}*=${f$2}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`, "g"), + g = /'/g, + $ = /"/g, + y = /^(?:script|style|textarea|title)$/i, + x = + (t) => + (i, ...s) => ({ + _$litType$: t, + strings: i, + values: s, + }), + b = x(1), + w = x(2); +x(3); +const E = Symbol.for("lit-noChange"), + A = Symbol.for("lit-nothing"), + C = /* @__PURE__ */ new WeakMap(), + P = l$3.createTreeWalker(l$3, 129); +function V(t, i) { + if (!u$2(t) || !t.hasOwnProperty("raw")) throw Error("invalid template strings array"); + return void 0 !== e$11 ? e$11.createHTML(i) : i; +} +const N = (t, i) => { + const s = t.length - 1, + e = []; + let n, + l = 2 === i ? "" : 3 === i ? "" : "", + c = v$1; + for (let i = 0; i < s; i++) { + const s = t[i]; + let a, + u, + d = -1, + f = 0; + for (; f < s.length && ((c.lastIndex = f), (u = c.exec(s)), null !== u); ) + ((f = c.lastIndex), + c === v$1 + ? "!--" === u[1] + ? (c = _) + : void 0 !== u[1] + ? (c = m$2) + : void 0 !== u[2] + ? (y.test(u[2]) && (n = RegExp("" === u[0] + ? ((c = n ?? v$1), (d = -1)) + : void 0 === u[1] + ? (d = -2) + : ((d = c.lastIndex - u[2].length), + (a = u[1]), + (c = void 0 === u[3] ? p$1 : '"' === u[3] ? $ : g)) + : c === $ || c === g + ? (c = p$1) + : c === _ || c === m$2 + ? (c = v$1) + : ((c = p$1), (n = void 0))); + const x = c === p$1 && t[i + 1].startsWith("/>") ? " " : ""; + l += + c === v$1 + ? s + r$9 + : d >= 0 + ? (e.push(a), s.slice(0, d) + h$5 + s.slice(d) + o$12 + x) + : s + o$12 + (-2 === d ? i : x); + } + return [V(t, l + (t[s] || "") + (2 === i ? "" : 3 === i ? "" : "")), e]; +}; +var S = class S { + constructor({ strings: t, _$litType$: i }, e) { + let r; + this.parts = []; + let l = 0, + a = 0; + const u = t.length - 1, + d = this.parts, + [f, v] = N(t, i); + if ( + ((this.el = S.createElement(f, e)), (P.currentNode = this.el.content), 2 === i || 3 === i) + ) { + const t = this.el.content.firstChild; + t.replaceWith(...t.childNodes); + } + for (; null !== (r = P.nextNode()) && d.length < u; ) { + if (1 === r.nodeType) { + if (r.hasAttributes()) + for (const t of r.getAttributeNames()) + if (t.endsWith(h$5)) { + const i = v[a++], + s = r.getAttribute(t).split(o$12), + e = /([.?@])?(.*)/.exec(i); + (d.push({ + type: 1, + index: l, + name: e[2], + strings: s, + ctor: "." === e[1] ? I : "?" === e[1] ? L : "@" === e[1] ? z : H, + }), + r.removeAttribute(t)); + } else + t.startsWith(o$12) && + (d.push({ + type: 6, + index: l, + }), + r.removeAttribute(t)); + if (y.test(r.tagName)) { + const t = r.textContent.split(o$12), + i = t.length - 1; + if (i > 0) { + r.textContent = s$7 ? s$7.emptyScript : ""; + for (let s = 0; s < i; s++) + (r.append(t[s], c$4()), + P.nextNode(), + d.push({ + type: 2, + index: ++l, + })); + r.append(t[i], c$4()); + } + } + } else if (8 === r.nodeType) + if (r.data === n$10) + d.push({ + type: 2, + index: l, + }); + else { + let t = -1; + for (; -1 !== (t = r.data.indexOf(o$12, t + 1)); ) + (d.push({ + type: 7, + index: l, + }), + (t += o$12.length - 1)); + } + l++; + } + } + static createElement(t, i) { + const s = l$3.createElement("template"); + return ((s.innerHTML = t), s); + } +}; +function M$1(t, i, s = t, e) { + if (i === E) return i; + let h = void 0 !== e ? s._$Co?.[e] : s._$Cl; + const o = a(i) ? void 0 : i._$litDirective$; + return ( + h?.constructor !== o && + (h?._$AO?.(!1), + void 0 === o ? (h = void 0) : ((h = new o(t)), h._$AT(t, s, e)), + void 0 !== e ? ((s._$Co ??= [])[e] = h) : (s._$Cl = h)), + void 0 !== h && (i = M$1(t, h._$AS(t, i.values), h, e)), + i + ); +} +var R = class { + constructor(t, i) { + ((this._$AV = []), (this._$AN = void 0), (this._$AD = t), (this._$AM = i)); + } + get parentNode() { + return this._$AM.parentNode; + } + get _$AU() { + return this._$AM._$AU; + } + u(t) { + const { + el: { content: i }, + parts: s, + } = this._$AD, + e = (t?.creationScope ?? l$3).importNode(i, !0); + P.currentNode = e; + let h = P.nextNode(), + o = 0, + n = 0, + r = s[0]; + for (; void 0 !== r; ) { + if (o === r.index) { + let i; + (2 === r.type + ? (i = new k(h, h.nextSibling, this, t)) + : 1 === r.type + ? (i = new r.ctor(h, r.name, r.strings, this, t)) + : 6 === r.type && (i = new Z(h, this, t)), + this._$AV.push(i), + (r = s[++n])); + } + o !== r?.index && ((h = P.nextNode()), o++); + } + return ((P.currentNode = l$3), e); + } + p(t) { + let i = 0; + for (const s of this._$AV) + (void 0 !== s && + (void 0 !== s.strings ? (s._$AI(t, s, i), (i += s.strings.length - 2)) : s._$AI(t[i])), + i++); + } +}; +var k = class k { + get _$AU() { + return this._$AM?._$AU ?? this._$Cv; + } + constructor(t, i, s, e) { + ((this.type = 2), + (this._$AH = A), + (this._$AN = void 0), + (this._$AA = t), + (this._$AB = i), + (this._$AM = s), + (this.options = e), + (this._$Cv = e?.isConnected ?? !0)); + } + get parentNode() { + let t = this._$AA.parentNode; + const i = this._$AM; + return (void 0 !== i && 11 === t?.nodeType && (t = i.parentNode), t); + } + get startNode() { + return this._$AA; + } + get endNode() { + return this._$AB; + } + _$AI(t, i = this) { + ((t = M$1(this, t, i)), + a(t) + ? t === A || null == t || "" === t + ? (this._$AH !== A && this._$AR(), (this._$AH = A)) + : t !== this._$AH && t !== E && this._(t) + : void 0 !== t._$litType$ + ? this.$(t) + : void 0 !== t.nodeType + ? this.T(t) + : d$1(t) + ? this.k(t) + : this._(t)); + } + O(t) { + return this._$AA.parentNode.insertBefore(t, this._$AB); + } + T(t) { + this._$AH !== t && (this._$AR(), (this._$AH = this.O(t))); + } + _(t) { + (this._$AH !== A && a(this._$AH) + ? (this._$AA.nextSibling.data = t) + : this.T(l$3.createTextNode(t)), + (this._$AH = t)); + } + $(t) { + const { values: i, _$litType$: s } = t, + e = + "number" == typeof s + ? this._$AC(t) + : (void 0 === s.el && (s.el = S.createElement(V(s.h, s.h[0]), this.options)), s); + if (this._$AH?._$AD === e) this._$AH.p(i); + else { + const t = new R(e, this), + s = t.u(this.options); + (t.p(i), this.T(s), (this._$AH = t)); + } + } + _$AC(t) { + let i = C.get(t.strings); + return (void 0 === i && C.set(t.strings, (i = new S(t))), i); + } + k(t) { + u$2(this._$AH) || ((this._$AH = []), this._$AR()); + const i = this._$AH; + let s, + e = 0; + for (const h of t) + (e === i.length + ? i.push((s = new k(this.O(c$4()), this.O(c$4()), this, this.options))) + : (s = i[e]), + s._$AI(h), + e++); + e < i.length && (this._$AR(s && s._$AB.nextSibling, e), (i.length = e)); + } + _$AR(t = this._$AA.nextSibling, s) { + for (this._$AP?.(!1, !0, s); t !== this._$AB; ) { + const s = i$7(t).nextSibling; + (i$7(t).remove(), (t = s)); + } + } + setConnected(t) { + void 0 === this._$AM && ((this._$Cv = t), this._$AP?.(t)); + } +}; +var H = class { + get tagName() { + return this.element.tagName; + } + get _$AU() { + return this._$AM._$AU; + } + constructor(t, i, s, e, h) { + ((this.type = 1), + (this._$AH = A), + (this._$AN = void 0), + (this.element = t), + (this.name = i), + (this._$AM = e), + (this.options = h), + s.length > 2 || "" !== s[0] || "" !== s[1] + ? ((this._$AH = Array(s.length - 1).fill(/* @__PURE__ */ new String())), (this.strings = s)) + : (this._$AH = A)); + } + _$AI(t, i = this, s, e) { + const h = this.strings; + let o = !1; + if (void 0 === h) + ((t = M$1(this, t, i, 0)), (o = !a(t) || (t !== this._$AH && t !== E)), o && (this._$AH = t)); + else { + const e = t; + let n, r; + for (t = h[0], n = 0; n < h.length - 1; n++) + ((r = M$1(this, e[s + n], i, n)), + r === E && (r = this._$AH[n]), + (o ||= !a(r) || r !== this._$AH[n]), + r === A ? (t = A) : t !== A && (t += (r ?? "") + h[n + 1]), + (this._$AH[n] = r)); + } + o && !e && this.j(t); + } + j(t) { + t === A + ? this.element.removeAttribute(this.name) + : this.element.setAttribute(this.name, t ?? ""); + } +}; +var I = class extends H { + constructor() { + (super(...arguments), (this.type = 3)); + } + j(t) { + this.element[this.name] = t === A ? void 0 : t; + } +}; +var L = class extends H { + constructor() { + (super(...arguments), (this.type = 4)); + } + j(t) { + this.element.toggleAttribute(this.name, !!t && t !== A); + } +}; +var z = class extends H { + constructor(t, i, s, e, h) { + (super(t, i, s, e, h), (this.type = 5)); + } + _$AI(t, i = this) { + if ((t = M$1(this, t, i, 0) ?? A) === E) return; + const s = this._$AH, + e = + (t === A && s !== A) || + t.capture !== s.capture || + t.once !== s.once || + t.passive !== s.passive, + h = t !== A && (s === A || e); + (e && this.element.removeEventListener(this.name, this, s), + h && this.element.addEventListener(this.name, this, t), + (this._$AH = t)); + } + handleEvent(t) { + "function" == typeof this._$AH + ? this._$AH.call(this.options?.host ?? this.element, t) + : this._$AH.handleEvent(t); + } +}; +var Z = class { + constructor(t, i, s) { + ((this.element = t), + (this.type = 6), + (this._$AN = void 0), + (this._$AM = i), + (this.options = s)); + } + get _$AU() { + return this._$AM._$AU; + } + _$AI(t) { + M$1(this, t); + } +}; +const j$1 = { + M: h$5, + P: o$12, + A: n$10, + C: 1, + L: N, + R, + D: d$1, + V: M$1, + I: k, + H, + N: L, + U: z, + B: I, + F: Z, + }, + B = t$5.litHtmlPolyfillSupport; +(B?.(S, k), (t$5.litHtmlVersions ??= []).push("3.3.2")); +const D = (t, i, s) => { + const e = s?.renderBefore ?? i; + let h = e._$litPart$; + if (void 0 === h) { + const t = s?.renderBefore ?? null; + e._$litPart$ = h = new k(i.insertBefore(c$4(), t), t, void 0, s ?? {}); + } + return (h._$AI(t), h); +}; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ const s$6 = globalThis; +var i$6 = class extends y$1 { + constructor() { + (super(...arguments), (this.renderOptions = { host: this }), (this._$Do = void 0)); + } + createRenderRoot() { + const t = super.createRenderRoot(); + return ((this.renderOptions.renderBefore ??= t.firstChild), t); + } + update(t) { + const r = this.render(); + (this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), + super.update(t), + (this._$Do = D(r, this.renderRoot, this.renderOptions))); + } + connectedCallback() { + (super.connectedCallback(), this._$Do?.setConnected(!0)); + } + disconnectedCallback() { + (super.disconnectedCallback(), this._$Do?.setConnected(!1)); + } + render() { + return E; + } +}; +((i$6._$litElement$ = !0), + (i$6["finalized"] = !0), + s$6.litElementHydrateSupport?.({ LitElement: i$6 })); +const o$11 = s$6.litElementPolyfillSupport; +o$11?.({ LitElement: i$6 }); +(s$6.litElementVersions ??= []).push("4.2.2"); +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const t$4 = { + ATTRIBUTE: 1, + CHILD: 2, + PROPERTY: 3, + BOOLEAN_ATTRIBUTE: 4, + EVENT: 5, + ELEMENT: 6, + }, + e$10 = + (t) => + (...e) => ({ + _$litDirective$: t, + values: e, + }); +var i$5 = class { + constructor(t) {} + get _$AU() { + return this._$AM._$AU; + } + _$AT(t, e, i) { + ((this._$Ct = t), (this._$AM = e), (this._$Ci = i)); + } + _$AS(t, e) { + return this.update(t, e); + } + update(t, e) { + return this.render(...e); + } +}; +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ const { I: t$3 } = j$1, + i$4 = (o) => o, + r$8 = (o) => void 0 === o.strings, + s$5 = () => document.createComment(""), + v = (o, n, e) => { + const l = o._$AA.parentNode, + d = void 0 === n ? o._$AB : n._$AA; + if (void 0 === e) e = new t$3(l.insertBefore(s$5(), d), l.insertBefore(s$5(), d), o, o.options); + else { + const t = e._$AB.nextSibling, + n = e._$AM, + c = n !== o; + if (c) { + let t; + (e._$AQ?.(o), (e._$AM = o), void 0 !== e._$AP && (t = o._$AU) !== n._$AU && e._$AP(t)); + } + if (t !== d || c) { + let o = e._$AA; + for (; o !== t; ) { + const t = i$4(o).nextSibling; + (i$4(l).insertBefore(o, d), (o = t)); + } + } + } + return e; + }, + u$1 = (o, t, i = o) => (o._$AI(t, i), o), + m$1 = {}, + p = (o, t = m$1) => (o._$AH = t), + M = (o) => o._$AH, + h$4 = (o) => { + (o._$AR(), o._$AA.remove()); + }; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const u = (e, s, t) => { + const r = /* @__PURE__ */ new Map(); + for (let l = s; l <= t; l++) r.set(e[l], l); + return r; + }, + c$2 = e$10( + class extends i$5 { + constructor(e) { + if ((super(e), e.type !== t$4.CHILD)) + throw Error("repeat() can only be used in text expressions"); + } + dt(e, s, t) { + let r; + void 0 === t ? (t = s) : void 0 !== s && (r = s); + const l = [], + o = []; + let i = 0; + for (const s of e) ((l[i] = r ? r(s, i) : i), (o[i] = t(s, i)), i++); + return { + values: o, + keys: l, + }; + } + render(e, s, t) { + return this.dt(e, s, t).values; + } + update(s, [t, r, c]) { + const d = M(s), + { values: p$3, keys: a } = this.dt(t, r, c); + if (!Array.isArray(d)) return ((this.ut = a), p$3); + const h = (this.ut ??= []), + v$2 = []; + let m, + y, + x = 0, + j = d.length - 1, + k = 0, + w = p$3.length - 1; + for (; x <= j && k <= w; ) + if (null === d[x]) x++; + else if (null === d[j]) j--; + else if (h[x] === a[k]) ((v$2[k] = u$1(d[x], p$3[k])), x++, k++); + else if (h[j] === a[w]) ((v$2[w] = u$1(d[j], p$3[w])), j--, w--); + else if (h[x] === a[w]) ((v$2[w] = u$1(d[x], p$3[w])), v(s, v$2[w + 1], d[x]), x++, w--); + else if (h[j] === a[k]) ((v$2[k] = u$1(d[j], p$3[k])), v(s, d[x], d[j]), j--, k++); + else if ((void 0 === m && ((m = u(a, k, w)), (y = u(h, x, j))), m.has(h[x]))) + if (m.has(h[j])) { + const e = y.get(a[k]), + t = void 0 !== e ? d[e] : null; + if (null === t) { + const e = v(s, d[x]); + (u$1(e, p$3[k]), (v$2[k] = e)); + } else ((v$2[k] = u$1(t, p$3[k])), v(s, d[x], t), (d[e] = null)); + k++; + } else (h$4(d[j]), j--); + else (h$4(d[x]), x++); + for (; k <= w; ) { + const e = v(s, v$2[w + 1]); + (u$1(e, p$3[k]), (v$2[k++] = e)); + } + for (; x <= j; ) { + const e = d[x++]; + null !== e && h$4(e); + } + return ((this.ut = a), p(s, v$2), E); + } + }, + ); +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +var s$4 = class extends Event { + constructor(s, t, e, o) { + (super("context-request", { + bubbles: !0, + composed: !0, + }), + (this.context = s), + (this.contextTarget = t), + (this.callback = e), + (this.subscribe = o ?? !1)); + } +}; +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +function n$7(n) { + return n; +} +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ var s$3 = class { + constructor(t, s, i, h) { + if ( + ((this.subscribe = !1), + (this.provided = !1), + (this.value = void 0), + (this.t = (t, s) => { + (this.unsubscribe && + (this.unsubscribe !== s && ((this.provided = !1), this.unsubscribe()), + this.subscribe || this.unsubscribe()), + (this.value = t), + this.host.requestUpdate(), + (this.provided && !this.subscribe) || + ((this.provided = !0), this.callback && this.callback(t, s)), + (this.unsubscribe = s)); + }), + (this.host = t), + void 0 !== s.context) + ) { + const t = s; + ((this.context = t.context), + (this.callback = t.callback), + (this.subscribe = t.subscribe ?? !1)); + } else ((this.context = s), (this.callback = i), (this.subscribe = h ?? !1)); + this.host.addController(this); + } + hostConnected() { + this.dispatchRequest(); + } + hostDisconnected() { + this.unsubscribe && (this.unsubscribe(), (this.unsubscribe = void 0)); + } + dispatchRequest() { + this.host.dispatchEvent(new s$4(this.context, this.host, this.t, this.subscribe)); + } +}; +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +var s$2 = class { + get value() { + return this.o; + } + set value(s) { + this.setValue(s); + } + setValue(s, t = !1) { + const i = t || !Object.is(s, this.o); + ((this.o = s), i && this.updateObservers()); + } + constructor(s) { + ((this.subscriptions = /* @__PURE__ */ new Map()), + (this.updateObservers = () => { + for (const [s, { disposer: t }] of this.subscriptions) s(this.o, t); + }), + void 0 !== s && (this.value = s)); + } + addCallback(s, t, i) { + if (!i) return void s(this.value); + this.subscriptions.has(s) || + this.subscriptions.set(s, { + disposer: () => { + this.subscriptions.delete(s); + }, + consumerHost: t, + }); + const { disposer: h } = this.subscriptions.get(s); + s(this.value, h); + } + clearCallbacks() { + this.subscriptions.clear(); + } +}; +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ var e$8 = class extends Event { + constructor(t, s) { + (super("context-provider", { + bubbles: !0, + composed: !0, + }), + (this.context = t), + (this.contextTarget = s)); + } +}; +var i$3 = class extends s$2 { + constructor(s, e, i) { + (super(void 0 !== e.context ? e.initialValue : i), + (this.onContextRequest = (t) => { + if (t.context !== this.context) return; + const s = t.contextTarget ?? t.composedPath()[0]; + s !== this.host && (t.stopPropagation(), this.addCallback(t.callback, s, t.subscribe)); + }), + (this.onProviderRequest = (s) => { + if (s.context !== this.context) return; + if ((s.contextTarget ?? s.composedPath()[0]) === this.host) return; + const e = /* @__PURE__ */ new Set(); + for (const [s, { consumerHost: i }] of this.subscriptions) + e.has(s) || (e.add(s), i.dispatchEvent(new s$4(this.context, i, s, !0))); + s.stopPropagation(); + }), + (this.host = s), + void 0 !== e.context ? (this.context = e.context) : (this.context = e), + this.attachListeners(), + this.host.addController?.(this)); + } + attachListeners() { + (this.host.addEventListener("context-request", this.onContextRequest), + this.host.addEventListener("context-provider", this.onProviderRequest)); + } + hostConnected() { + this.host.dispatchEvent(new e$8(this.context, this.host)); + } +}; +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ function c$1({ context: c, subscribe: e }) { + return (o, n) => { + "object" == typeof n + ? n.addInitializer(function () { + new s$3(this, { + context: c, + callback: (t) => { + o.set.call(this, t); + }, + subscribe: e, + }); + }) + : o.constructor.addInitializer((o) => { + new s$3(o, { + context: c, + callback: (t) => { + o[n] = t; + }, + subscribe: e, + }); + }); + }; +} +const eventInit = { + bubbles: true, + cancelable: true, + composed: true, +}; +var StateEvent = class StateEvent extends CustomEvent { + static { + this.eventName = "a2uiaction"; + } + constructor(payload) { + super(StateEvent.eventName, { + detail: payload, + ...eventInit, + }); + this.payload = payload; + } +}; +const opacityBehavior = ` + &:not([disabled]) { + cursor: pointer; + opacity: var(--opacity, 0); + transition: opacity var(--speed, 0.2s) cubic-bezier(0, 0, 0.3, 1); + + &:hover, + &:focus { + opacity: 1; + } + }`; +const behavior = ` + ${new Array(21) + .fill(0) + .map((_, idx) => { + return `.behavior-ho-${idx * 5} { + --opacity: ${idx / 20}; + ${opacityBehavior} + }`; + }) + .join("\n")} + + .behavior-o-s { + overflow: scroll; + } + + .behavior-o-a { + overflow: auto; + } + + .behavior-o-h { + overflow: hidden; + } + + .behavior-sw-n { + scrollbar-width: none; + } +`; +const border = ` + ${new Array(25) + .fill(0) + .map((_, idx) => { + return ` + .border-bw-${idx} { border-width: ${idx}px; } + .border-btw-${idx} { border-top-width: ${idx}px; } + .border-bbw-${idx} { border-bottom-width: ${idx}px; } + .border-blw-${idx} { border-left-width: ${idx}px; } + .border-brw-${idx} { border-right-width: ${idx}px; } + + .border-ow-${idx} { outline-width: ${idx}px; } + .border-br-${idx} { border-radius: ${idx * 4}px; overflow: hidden;}`; + }) + .join("\n")} + + .border-br-50pc { + border-radius: 50%; + } + + .border-bs-s { + border-style: solid; + } +`; +const shades = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]; +function merge(...classes) { + const styles = {}; + for (const clazz of classes) + for (const [key, val] of Object.entries(clazz)) { + const prefix = key.split("-").with(-1, "").join("-"); + const existingKeys = Object.keys(styles).filter((key) => key.startsWith(prefix)); + for (const existingKey of existingKeys) delete styles[existingKey]; + styles[key] = val; + } + return styles; +} +function appendToAll(target, exclusions, ...classes) { + const updatedTarget = structuredClone(target); + for (const clazz of classes) + for (const key of Object.keys(clazz)) { + const prefix = key.split("-").with(-1, "").join("-"); + for (const [tagName, classesToAdd] of Object.entries(updatedTarget)) { + if (exclusions.includes(tagName)) continue; + let found = false; + for (let t = 0; t < classesToAdd.length; t++) + if (classesToAdd[t].startsWith(prefix)) { + found = true; + classesToAdd[t] = key; + } + if (!found) classesToAdd.push(key); + } + } + return updatedTarget; +} +function toProp(key) { + if (key.startsWith("nv")) return `--nv-${key.slice(2)}`; + return `--${key[0]}-${key.slice(1)}`; +} +const color = (src) => ` + ${src + .map((key) => { + const inverseKey = getInverseKey(key); + return `.color-bc-${key} { border-color: light-dark(var(${toProp(key)}), var(${toProp(inverseKey)})); }`; + }) + .join("\n")} + + ${src + .map((key) => { + const inverseKey = getInverseKey(key); + const vals = [ + `.color-bgc-${key} { background-color: light-dark(var(${toProp(key)}), var(${toProp(inverseKey)})); }`, + `.color-bbgc-${key}::backdrop { background-color: light-dark(var(${toProp(key)}), var(${toProp(inverseKey)})); }`, + ]; + for (let o = 0.1; o < 1; o += 0.1) + vals.push(`.color-bbgc-${key}_${(o * 100).toFixed(0)}::backdrop { + background-color: light-dark(oklch(from var(${toProp(key)}) l c h / calc(alpha * ${o.toFixed(1)})), oklch(from var(${toProp(inverseKey)}) l c h / calc(alpha * ${o.toFixed(1)})) ); + } + `); + return vals.join("\n"); + }) + .join("\n")} + + ${src + .map((key) => { + const inverseKey = getInverseKey(key); + return `.color-c-${key} { color: light-dark(var(${toProp(key)}), var(${toProp(inverseKey)})); }`; + }) + .join("\n")} + `; +const getInverseKey = (key) => { + const match = key.match(/^([a-z]+)(\d+)$/); + if (!match) return key; + const [, prefix, shadeStr] = match; + const target = 100 - parseInt(shadeStr, 10); + return `${prefix}${shades.reduce((prev, curr) => (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev))}`; +}; +const keyFactory = (prefix) => { + return shades.map((v) => `${prefix}${v}`); +}; +const structuralStyles$1 = [ + behavior, + border, + [ + color(keyFactory("p")), + color(keyFactory("s")), + color(keyFactory("t")), + color(keyFactory("n")), + color(keyFactory("nv")), + color(keyFactory("e")), + ` + .color-bgc-transparent { + background-color: transparent; + } + + :host { + color-scheme: var(--color-scheme); + } + `, + ], + ` + .g-icon { + font-family: "Material Symbols Outlined", "Google Symbols"; + font-weight: normal; + font-style: normal; + font-display: optional; + font-size: 20px; + width: 1em; + height: 1em; + user-select: none; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -webkit-font-feature-settings: "liga"; + -webkit-font-smoothing: antialiased; + overflow: hidden; + + font-variation-settings: "FILL" 0, "wght" 300, "GRAD" 0, "opsz" 48, + "ROND" 100; + + &.filled { + font-variation-settings: "FILL" 1, "wght" 300, "GRAD" 0, "opsz" 48, + "ROND" 100; + } + + &.filled-heavy { + font-variation-settings: "FILL" 1, "wght" 700, "GRAD" 0, "opsz" 48, + "ROND" 100; + } + } +`, + ` + :host { + ${new Array(16) + .fill(0) + .map((_, idx) => { + return `--g-${idx + 1}: ${(idx + 1) * 4}px;`; + }) + .join("\n")} + } + + ${new Array(49) + .fill(0) + .map((_, index) => { + const idx = index - 24; + const lbl = idx < 0 ? `n${Math.abs(idx)}` : idx.toString(); + return ` + .layout-p-${lbl} { --padding: ${idx * 4}px; padding: var(--padding); } + .layout-pt-${lbl} { padding-top: ${idx * 4}px; } + .layout-pr-${lbl} { padding-right: ${idx * 4}px; } + .layout-pb-${lbl} { padding-bottom: ${idx * 4}px; } + .layout-pl-${lbl} { padding-left: ${idx * 4}px; } + + .layout-m-${lbl} { --margin: ${idx * 4}px; margin: var(--margin); } + .layout-mt-${lbl} { margin-top: ${idx * 4}px; } + .layout-mr-${lbl} { margin-right: ${idx * 4}px; } + .layout-mb-${lbl} { margin-bottom: ${idx * 4}px; } + .layout-ml-${lbl} { margin-left: ${idx * 4}px; } + + .layout-t-${lbl} { top: ${idx * 4}px; } + .layout-r-${lbl} { right: ${idx * 4}px; } + .layout-b-${lbl} { bottom: ${idx * 4}px; } + .layout-l-${lbl} { left: ${idx * 4}px; }`; + }) + .join("\n")} + + ${new Array(25) + .fill(0) + .map((_, idx) => { + return ` + .layout-g-${idx} { gap: ${idx * 4}px; }`; + }) + .join("\n")} + + ${new Array(8) + .fill(0) + .map((_, idx) => { + return ` + .layout-grd-col${idx + 1} { grid-template-columns: ${"1fr ".repeat(idx + 1).trim()}; }`; + }) + .join("\n")} + + .layout-pos-a { + position: absolute; + } + + .layout-pos-rel { + position: relative; + } + + .layout-dsp-none { + display: none; + } + + .layout-dsp-block { + display: block; + } + + .layout-dsp-grid { + display: grid; + } + + .layout-dsp-iflex { + display: inline-flex; + } + + .layout-dsp-flexvert { + display: flex; + flex-direction: column; + } + + .layout-dsp-flexhor { + display: flex; + flex-direction: row; + } + + .layout-fw-w { + flex-wrap: wrap; + } + + .layout-al-fs { + align-items: start; + } + + .layout-al-fe { + align-items: end; + } + + .layout-al-c { + align-items: center; + } + + .layout-as-n { + align-self: normal; + } + + .layout-js-c { + justify-self: center; + } + + .layout-sp-c { + justify-content: center; + } + + .layout-sp-ev { + justify-content: space-evenly; + } + + .layout-sp-bt { + justify-content: space-between; + } + + .layout-sp-s { + justify-content: start; + } + + .layout-sp-e { + justify-content: end; + } + + .layout-ji-e { + justify-items: end; + } + + .layout-r-none { + resize: none; + } + + .layout-fs-c { + field-sizing: content; + } + + .layout-fs-n { + field-sizing: none; + } + + .layout-flx-0 { + flex: 0 0 auto; + } + + .layout-flx-1 { + flex: 1 0 auto; + } + + .layout-c-s { + contain: strict; + } + + /** Widths **/ + + ${new Array(10) + .fill(0) + .map((_, idx) => { + const weight = (idx + 1) * 10; + return `.layout-w-${weight} { width: ${weight}%; max-width: ${weight}%; }`; + }) + .join("\n")} + + ${new Array(16) + .fill(0) + .map((_, idx) => { + return `.layout-wp-${idx} { width: ${idx * 4}px; }`; + }) + .join("\n")} + + /** Heights **/ + + ${new Array(10) + .fill(0) + .map((_, idx) => { + const height = (idx + 1) * 10; + return `.layout-h-${height} { height: ${height}%; }`; + }) + .join("\n")} + + ${new Array(16) + .fill(0) + .map((_, idx) => { + return `.layout-hp-${idx} { height: ${idx * 4}px; }`; + }) + .join("\n")} + + .layout-el-cv { + & img, + & video { + width: 100%; + height: 100%; + object-fit: cover; + margin: 0; + } + } + + .layout-ar-sq { + aspect-ratio: 1 / 1; + } + + .layout-ex-fb { + margin: calc(var(--padding) * -1) 0 0 calc(var(--padding) * -1); + width: calc(100% + var(--padding) * 2); + height: calc(100% + var(--padding) * 2); + } +`, + ` + ${new Array(21) + .fill(0) + .map((_, idx) => { + return `.opacity-el-${idx * 5} { opacity: ${idx / 20}; }`; + }) + .join("\n")} +`, + ` + :host { + --default-font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + --default-font-family-mono: "Courier New", Courier, monospace; + } + + .typography-f-s { + font-family: var(--font-family, var(--default-font-family)); + font-optical-sizing: auto; + font-variation-settings: "slnt" 0, "wdth" 100, "GRAD" 0; + } + + .typography-f-sf { + font-family: var(--font-family-flex, var(--default-font-family)); + font-optical-sizing: auto; + } + + .typography-f-c { + font-family: var(--font-family-mono, var(--default-font-family)); + font-optical-sizing: auto; + font-variation-settings: "slnt" 0, "wdth" 100, "GRAD" 0; + } + + .typography-v-r { + font-variation-settings: "slnt" 0, "wdth" 100, "GRAD" 0, "ROND" 100; + } + + .typography-ta-s { + text-align: start; + } + + .typography-ta-c { + text-align: center; + } + + .typography-fs-n { + font-style: normal; + } + + .typography-fs-i { + font-style: italic; + } + + .typography-sz-ls { + font-size: 11px; + line-height: 16px; + } + + .typography-sz-lm { + font-size: 12px; + line-height: 16px; + } + + .typography-sz-ll { + font-size: 14px; + line-height: 20px; + } + + .typography-sz-bs { + font-size: 12px; + line-height: 16px; + } + + .typography-sz-bm { + font-size: 14px; + line-height: 20px; + } + + .typography-sz-bl { + font-size: 16px; + line-height: 24px; + } + + .typography-sz-ts { + font-size: 14px; + line-height: 20px; + } + + .typography-sz-tm { + font-size: 16px; + line-height: 24px; + } + + .typography-sz-tl { + font-size: 22px; + line-height: 28px; + } + + .typography-sz-hs { + font-size: 24px; + line-height: 32px; + } + + .typography-sz-hm { + font-size: 28px; + line-height: 36px; + } + + .typography-sz-hl { + font-size: 32px; + line-height: 40px; + } + + .typography-sz-ds { + font-size: 36px; + line-height: 44px; + } + + .typography-sz-dm { + font-size: 45px; + line-height: 52px; + } + + .typography-sz-dl { + font-size: 57px; + line-height: 64px; + } + + .typography-ws-p { + white-space: pre-line; + } + + .typography-ws-nw { + white-space: nowrap; + } + + .typography-td-none { + text-decoration: none; + } + + /** Weights **/ + + ${new Array(9) + .fill(0) + .map((_, idx) => { + const weight = (idx + 1) * 100; + return `.typography-w-${weight} { font-weight: ${weight}; }`; + }) + .join("\n")} +`, +] + .flat(Infinity) + .join("\n"); +var guards_exports = /* @__PURE__ */ __exportAll({ + isComponentArrayReference: () => isComponentArrayReference, + isObject: () => isObject$1, + isPath: () => isPath, + isResolvedAudioPlayer: () => isResolvedAudioPlayer, + isResolvedButton: () => isResolvedButton, + isResolvedCard: () => isResolvedCard, + isResolvedCheckbox: () => isResolvedCheckbox, + isResolvedColumn: () => isResolvedColumn, + isResolvedDateTimeInput: () => isResolvedDateTimeInput, + isResolvedDivider: () => isResolvedDivider, + isResolvedIcon: () => isResolvedIcon, + isResolvedImage: () => isResolvedImage, + isResolvedList: () => isResolvedList, + isResolvedModal: () => isResolvedModal, + isResolvedMultipleChoice: () => isResolvedMultipleChoice, + isResolvedRow: () => isResolvedRow, + isResolvedSlider: () => isResolvedSlider, + isResolvedTabs: () => isResolvedTabs, + isResolvedText: () => isResolvedText, + isResolvedTextField: () => isResolvedTextField, + isResolvedVideo: () => isResolvedVideo, + isValueMap: () => isValueMap, +}); +function isValueMap(value) { + return isObject$1(value) && "key" in value; +} +function isPath(key, value) { + return key === "path" && typeof value === "string"; +} +function isObject$1(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function isComponentArrayReference(value) { + if (!isObject$1(value)) return false; + return "explicitList" in value || "template" in value; +} +function isStringValue(value) { + return ( + isObject$1(value) && + ("path" in value || + ("literal" in value && typeof value.literal === "string") || + "literalString" in value) + ); +} +function isNumberValue(value) { + return ( + isObject$1(value) && + ("path" in value || + ("literal" in value && typeof value.literal === "number") || + "literalNumber" in value) + ); +} +function isBooleanValue(value) { + return ( + isObject$1(value) && + ("path" in value || + ("literal" in value && typeof value.literal === "boolean") || + "literalBoolean" in value) + ); +} +function isAnyComponentNode(value) { + if (!isObject$1(value)) return false; + if (!("id" in value && "type" in value && "properties" in value)) return false; + return true; +} +function isResolvedAudioPlayer(props) { + return isObject$1(props) && "url" in props && isStringValue(props.url); +} +function isResolvedButton(props) { + return ( + isObject$1(props) && "child" in props && isAnyComponentNode(props.child) && "action" in props + ); +} +function isResolvedCard(props) { + if (!isObject$1(props)) return false; + if (!("child" in props)) + if (!("children" in props)) return false; + else return Array.isArray(props.children) && props.children.every(isAnyComponentNode); + return isAnyComponentNode(props.child); +} +function isResolvedCheckbox(props) { + return ( + isObject$1(props) && + "label" in props && + isStringValue(props.label) && + "value" in props && + isBooleanValue(props.value) + ); +} +function isResolvedColumn(props) { + return ( + isObject$1(props) && + "children" in props && + Array.isArray(props.children) && + props.children.every(isAnyComponentNode) + ); +} +function isResolvedDateTimeInput(props) { + return isObject$1(props) && "value" in props && isStringValue(props.value); +} +function isResolvedDivider(props) { + return isObject$1(props); +} +function isResolvedImage(props) { + return isObject$1(props) && "url" in props && isStringValue(props.url); +} +function isResolvedIcon(props) { + return isObject$1(props) && "name" in props && isStringValue(props.name); +} +function isResolvedList(props) { + return ( + isObject$1(props) && + "children" in props && + Array.isArray(props.children) && + props.children.every(isAnyComponentNode) + ); +} +function isResolvedModal(props) { + return ( + isObject$1(props) && + "entryPointChild" in props && + isAnyComponentNode(props.entryPointChild) && + "contentChild" in props && + isAnyComponentNode(props.contentChild) + ); +} +function isResolvedMultipleChoice(props) { + return isObject$1(props) && "selections" in props; +} +function isResolvedRow(props) { + return ( + isObject$1(props) && + "children" in props && + Array.isArray(props.children) && + props.children.every(isAnyComponentNode) + ); +} +function isResolvedSlider(props) { + return isObject$1(props) && "value" in props && isNumberValue(props.value); +} +function isResolvedTabItem(item) { + return ( + isObject$1(item) && + "title" in item && + isStringValue(item.title) && + "child" in item && + isAnyComponentNode(item.child) + ); +} +function isResolvedTabs(props) { + return ( + isObject$1(props) && + "tabItems" in props && + Array.isArray(props.tabItems) && + props.tabItems.every(isResolvedTabItem) + ); +} +function isResolvedText(props) { + return isObject$1(props) && "text" in props && isStringValue(props.text); +} +function isResolvedTextField(props) { + return isObject$1(props) && "label" in props && isStringValue(props.label); +} +function isResolvedVideo(props) { + return isObject$1(props) && "url" in props && isStringValue(props.url); +} +/** + * Processes and consolidates A2UIProtocolMessage objects into a structured, + * hierarchical model of UI surfaces. + */ +var A2uiMessageProcessor = class A2uiMessageProcessor { + static { + this.DEFAULT_SURFACE_ID = "@default"; + } + #mapCtor = Map; + #arrayCtor = Array; + #setCtor = Set; + #objCtor = Object; + #surfaces; + constructor( + opts = { + mapCtor: Map, + arrayCtor: Array, + setCtor: Set, + objCtor: Object, + }, + ) { + this.opts = opts; + this.#arrayCtor = opts.arrayCtor; + this.#mapCtor = opts.mapCtor; + this.#setCtor = opts.setCtor; + this.#objCtor = opts.objCtor; + this.#surfaces = new opts.mapCtor(); + } + getSurfaces() { + return this.#surfaces; + } + clearSurfaces() { + this.#surfaces.clear(); + } + processMessages(messages) { + for (const message of messages) { + if (message.beginRendering) + this.#handleBeginRendering(message.beginRendering, message.beginRendering.surfaceId); + if (message.surfaceUpdate) + this.#handleSurfaceUpdate(message.surfaceUpdate, message.surfaceUpdate.surfaceId); + if (message.dataModelUpdate) + this.#handleDataModelUpdate(message.dataModelUpdate, message.dataModelUpdate.surfaceId); + if (message.deleteSurface) this.#handleDeleteSurface(message.deleteSurface); + } + } + /** + * Retrieves the data for a given component node and a relative path string. + * This correctly handles the special `.` path, which refers to the node's + * own data context. + */ + getData(node, relativePath, surfaceId = A2uiMessageProcessor.DEFAULT_SURFACE_ID) { + const surface = this.#getOrCreateSurface(surfaceId); + if (!surface) return null; + let finalPath; + if (relativePath === "." || relativePath === "") finalPath = node.dataContextPath ?? "/"; + else finalPath = this.resolvePath(relativePath, node.dataContextPath); + return this.#getDataByPath(surface.dataModel, finalPath); + } + setData(node, relativePath, value, surfaceId = A2uiMessageProcessor.DEFAULT_SURFACE_ID) { + if (!node) { + console.warn("No component node set"); + return; + } + const surface = this.#getOrCreateSurface(surfaceId); + if (!surface) return; + let finalPath; + if (relativePath === "." || relativePath === "") finalPath = node.dataContextPath ?? "/"; + else finalPath = this.resolvePath(relativePath, node.dataContextPath); + this.#setDataByPath(surface.dataModel, finalPath, value); + } + resolvePath(path, dataContextPath) { + if (path.startsWith("/")) return path; + if (dataContextPath && dataContextPath !== "/") + return dataContextPath.endsWith("/") + ? `${dataContextPath}${path}` + : `${dataContextPath}/${path}`; + return `/${path}`; + } + #parseIfJsonString(value) { + if (typeof value !== "string") return value; + const trimmedValue = value.trim(); + if ( + (trimmedValue.startsWith("{") && trimmedValue.endsWith("}")) || + (trimmedValue.startsWith("[") && trimmedValue.endsWith("]")) + ) + try { + return JSON.parse(value); + } catch (e) { + console.warn(`Failed to parse potential JSON string: "${value.substring(0, 50)}..."`, e); + return value; + } + return value; + } + /** + * Converts a specific array format [{key: "...", value_string: "..."}, ...] + * into a standard Map. It also attempts to parse any string values that + * appear to be stringified JSON. + */ + #convertKeyValueArrayToMap(arr) { + const map = new this.#mapCtor(); + for (const item of arr) { + if (!isObject$1(item) || !("key" in item)) continue; + const key = item.key; + const valueKey = this.#findValueKey(item); + if (!valueKey) continue; + let value = item[valueKey]; + if (valueKey === "valueMap" && Array.isArray(value)) + value = this.#convertKeyValueArrayToMap(value); + else if (typeof value === "string") value = this.#parseIfJsonString(value); + this.#setDataByPath(map, key, value); + } + return map; + } + #setDataByPath(root, path, value) { + if (Array.isArray(value) && (value.length === 0 || (isObject$1(value[0]) && "key" in value[0]))) + if (value.length === 1 && isObject$1(value[0]) && value[0].key === ".") { + const item = value[0]; + const valueKey = this.#findValueKey(item); + if (valueKey) { + value = item[valueKey]; + if (valueKey === "valueMap" && Array.isArray(value)) + value = this.#convertKeyValueArrayToMap(value); + else if (typeof value === "string") value = this.#parseIfJsonString(value); + } else value = this.#convertKeyValueArrayToMap(value); + } else value = this.#convertKeyValueArrayToMap(value); + const segments = this.#normalizePath(path) + .split("/") + .filter((s) => s); + if (segments.length === 0) { + if (value instanceof Map || isObject$1(value)) { + if (!(value instanceof Map) && isObject$1(value)) + value = new this.#mapCtor(Object.entries(value)); + root.clear(); + for (const [key, v] of value.entries()) root.set(key, v); + } else console.error("Cannot set root of DataModel to a non-Map value."); + return; + } + let current = root; + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i]; + let target; + if (current instanceof Map) target = current.get(segment); + else if (Array.isArray(current) && /^\d+$/.test(segment)) + target = current[parseInt(segment, 10)]; + if (target === void 0 || typeof target !== "object" || target === null) { + target = new this.#mapCtor(); + if (current instanceof this.#mapCtor) current.set(segment, target); + else if (Array.isArray(current)) current[parseInt(segment, 10)] = target; + } + current = target; + } + const finalSegment = segments[segments.length - 1]; + const storedValue = value; + if (current instanceof this.#mapCtor) current.set(finalSegment, storedValue); + else if (Array.isArray(current) && /^\d+$/.test(finalSegment)) + current[parseInt(finalSegment, 10)] = storedValue; + } + /** + * Normalizes a path string into a consistent, slash-delimited format. + * Converts bracket notation and dot notation in a two-pass. + * e.g., "bookRecommendations[0].title" -> "/bookRecommendations/0/title" + * e.g., "book.0.title" -> "/book/0/title" + */ + #normalizePath(path) { + return ( + "/" + + path + .replace(/\[(\d+)\]/g, ".$1") + .split(".") + .filter((s) => s.length > 0) + .join("/") + ); + } + #getDataByPath(root, path) { + const segments = this.#normalizePath(path) + .split("/") + .filter((s) => s); + let current = root; + for (const segment of segments) { + if (current === void 0 || current === null) return null; + if (current instanceof Map) current = current.get(segment); + else if (Array.isArray(current) && /^\d+$/.test(segment)) + current = current[parseInt(segment, 10)]; + else if (isObject$1(current)) current = current[segment]; + else return null; + } + return current; + } + #getOrCreateSurface(surfaceId) { + let surface = this.#surfaces.get(surfaceId); + if (!surface) { + surface = new this.#objCtor({ + rootComponentId: null, + componentTree: null, + dataModel: new this.#mapCtor(), + components: new this.#mapCtor(), + styles: new this.#objCtor(), + }); + this.#surfaces.set(surfaceId, surface); + } + return surface; + } + #handleBeginRendering(message, surfaceId) { + const surface = this.#getOrCreateSurface(surfaceId); + surface.rootComponentId = message.root; + surface.styles = message.styles ?? {}; + this.#rebuildComponentTree(surface); + } + #handleSurfaceUpdate(message, surfaceId) { + const surface = this.#getOrCreateSurface(surfaceId); + for (const component of message.components) surface.components.set(component.id, component); + this.#rebuildComponentTree(surface); + } + #handleDataModelUpdate(message, surfaceId) { + const surface = this.#getOrCreateSurface(surfaceId); + const path = message.path ?? "/"; + this.#setDataByPath(surface.dataModel, path, message.contents); + this.#rebuildComponentTree(surface); + } + #handleDeleteSurface(message) { + this.#surfaces.delete(message.surfaceId); + } + /** + * Starts at the root component of the surface and builds out the tree + * recursively. This process involves resolving all properties of the child + * components, and expanding on any explicit children lists or templates + * found in the structure. + * + * @param surface The surface to be built. + */ + #rebuildComponentTree(surface) { + if (!surface.rootComponentId) { + surface.componentTree = null; + return; + } + const visited = new this.#setCtor(); + surface.componentTree = this.#buildNodeRecursive( + surface.rootComponentId, + surface, + visited, + "/", + "", + ); + } + /** Finds a value key in a map. */ + #findValueKey(value) { + return Object.keys(value).find((k) => k.startsWith("value")); + } + /** + * Builds out the nodes recursively. + */ + #buildNodeRecursive(baseComponentId, surface, visited, dataContextPath, idSuffix = "") { + const fullId = `${baseComponentId}${idSuffix}`; + const { components } = surface; + if (!components.has(baseComponentId)) return null; + if (visited.has(fullId)) throw new Error(`Circular dependency for component "${fullId}".`); + visited.add(fullId); + const componentData = components.get(baseComponentId); + const componentProps = componentData.component ?? {}; + const componentType = Object.keys(componentProps)[0]; + const unresolvedProperties = componentProps[componentType]; + const resolvedProperties = new this.#objCtor(); + if (isObject$1(unresolvedProperties)) + for (const [key, value] of Object.entries(unresolvedProperties)) + resolvedProperties[key] = this.#resolvePropertyValue( + value, + surface, + visited, + dataContextPath, + idSuffix, + key, + ); + visited.delete(fullId); + const baseNode = { + id: fullId, + dataContextPath, + weight: componentData.weight ?? "initial", + }; + switch (componentType) { + case "Text": + if (!isResolvedText(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Text", + properties: resolvedProperties, + }); + case "Image": + if (!isResolvedImage(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Image", + properties: resolvedProperties, + }); + case "Icon": + if (!isResolvedIcon(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Icon", + properties: resolvedProperties, + }); + case "Video": + if (!isResolvedVideo(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Video", + properties: resolvedProperties, + }); + case "AudioPlayer": + if (!isResolvedAudioPlayer(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "AudioPlayer", + properties: resolvedProperties, + }); + case "Row": + if (!isResolvedRow(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Row", + properties: resolvedProperties, + }); + case "Column": + if (!isResolvedColumn(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Column", + properties: resolvedProperties, + }); + case "List": + if (!isResolvedList(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "List", + properties: resolvedProperties, + }); + case "Card": + if (!isResolvedCard(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Card", + properties: resolvedProperties, + }); + case "Tabs": + if (!isResolvedTabs(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Tabs", + properties: resolvedProperties, + }); + case "Divider": + if (!isResolvedDivider(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Divider", + properties: resolvedProperties, + }); + case "Modal": + if (!isResolvedModal(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Modal", + properties: resolvedProperties, + }); + case "Button": + if (!isResolvedButton(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Button", + properties: resolvedProperties, + }); + case "CheckBox": + if (!isResolvedCheckbox(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "CheckBox", + properties: resolvedProperties, + }); + case "TextField": + if (!isResolvedTextField(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "TextField", + properties: resolvedProperties, + }); + case "DateTimeInput": + if (!isResolvedDateTimeInput(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "DateTimeInput", + properties: resolvedProperties, + }); + case "MultipleChoice": + if (!isResolvedMultipleChoice(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "MultipleChoice", + properties: resolvedProperties, + }); + case "Slider": + if (!isResolvedSlider(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Slider", + properties: resolvedProperties, + }); + default: + return new this.#objCtor({ + ...baseNode, + type: componentType, + properties: resolvedProperties, + }); + } + } + /** + * Recursively resolves an individual property value. If a property indicates + * a child node (a string that matches a component ID), an explicitList of + * children, or a template, these will be built out here. + */ + #resolvePropertyValue( + value, + surface, + visited, + dataContextPath, + idSuffix = "", + propertyKey = null, + ) { + const isComponentIdReferenceKey = (key) => key === "child" || key.endsWith("Child"); + if ( + typeof value === "string" && + propertyKey && + isComponentIdReferenceKey(propertyKey) && + surface.components.has(value) + ) + return this.#buildNodeRecursive(value, surface, visited, dataContextPath, idSuffix); + if (isComponentArrayReference(value)) { + if (value.explicitList) + return value.explicitList.map((id) => + this.#buildNodeRecursive(id, surface, visited, dataContextPath, idSuffix), + ); + if (value.template) { + const fullDataPath = this.resolvePath(value.template.dataBinding, dataContextPath); + const data = this.#getDataByPath(surface.dataModel, fullDataPath); + const template = value.template; + if (Array.isArray(data)) + return data.map((_, index) => { + const newSuffix = `:${[...dataContextPath.split("/").filter((segment) => /^\d+$/.test(segment)), index].join(":")}`; + const childDataContextPath = `${fullDataPath}/${index}`; + return this.#buildNodeRecursive( + template.componentId, + surface, + visited, + childDataContextPath, + newSuffix, + ); + }); + if (data instanceof this.#mapCtor) + return Array.from(data.keys(), (key) => { + const newSuffix = `:${key}`; + const childDataContextPath = `${fullDataPath}/${key}`; + return this.#buildNodeRecursive( + template.componentId, + surface, + visited, + childDataContextPath, + newSuffix, + ); + }); + return new this.#arrayCtor(); + } + } + if (Array.isArray(value)) + return value.map((item) => + this.#resolvePropertyValue(item, surface, visited, dataContextPath, idSuffix, propertyKey), + ); + if (isObject$1(value)) { + const newObj = new this.#objCtor(); + for (const [key, propValue] of Object.entries(value)) { + let propertyValue = propValue; + if (isPath(key, propValue) && dataContextPath !== "/") { + propertyValue = propValue + .replace(/^\.?\/item/, "") + .replace(/^\.?\/text/, "") + .replace(/^\.?\/label/, "") + .replace(/^\.?\//, ""); + newObj[key] = propertyValue; + continue; + } + newObj[key] = this.#resolvePropertyValue( + propertyValue, + surface, + visited, + dataContextPath, + idSuffix, + key, + ); + } + return newObj; + } + return value; + } +}; +var __defProp = Object.defineProperty; +var __defNormalProp = (obj, key, value) => + key in obj + ? __defProp(obj, key, { + enumerable: true, + configurable: true, + writable: true, + value, + }) + : (obj[key] = value); +var __publicField = (obj, key, value) => { + __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + return value; +}; +var __accessCheck = (obj, member, msg) => { + if (!member.has(obj)) throw TypeError("Cannot " + msg); +}; +var __privateIn = (member, obj) => { + if (Object(obj) !== obj) throw TypeError('Cannot use the "in" operator on this value'); + return member.has(obj); +}; +var __privateAdd = (obj, member, value) => { + if (member.has(obj)) throw TypeError("Cannot add the same private member more than once"); + member instanceof WeakSet ? member.add(obj) : member.set(obj, value); +}; +var __privateMethod = (obj, member, method) => { + __accessCheck(obj, member, "access private method"); + return method; +}; +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +function defaultEquals(a, b) { + return Object.is(a, b); +} +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +let activeConsumer = null; +let inNotificationPhase = false; +let epoch = 1; +const SIGNAL = /* @__PURE__ */ Symbol("SIGNAL"); +function setActiveConsumer(consumer) { + const prev = activeConsumer; + activeConsumer = consumer; + return prev; +} +function getActiveConsumer() { + return activeConsumer; +} +function isInNotificationPhase() { + return inNotificationPhase; +} +const REACTIVE_NODE = { + version: 0, + lastCleanEpoch: 0, + dirty: false, + producerNode: void 0, + producerLastReadVersion: void 0, + producerIndexOfThis: void 0, + nextProducerIndex: 0, + liveConsumerNode: void 0, + liveConsumerIndexOfThis: void 0, + consumerAllowSignalWrites: false, + consumerIsAlwaysLive: false, + producerMustRecompute: () => false, + producerRecomputeValue: () => {}, + consumerMarkedDirty: () => {}, + consumerOnSignalRead: () => {}, +}; +function producerAccessed(node) { + if (inNotificationPhase) + throw new Error( + typeof ngDevMode !== "undefined" && ngDevMode + ? `Assertion error: signal read during notification phase` + : "", + ); + if (activeConsumer === null) return; + activeConsumer.consumerOnSignalRead(node); + const idx = activeConsumer.nextProducerIndex++; + assertConsumerNode(activeConsumer); + if (idx < activeConsumer.producerNode.length && activeConsumer.producerNode[idx] !== node) { + if (consumerIsLive(activeConsumer)) { + const staleProducer = activeConsumer.producerNode[idx]; + producerRemoveLiveConsumerAtIndex(staleProducer, activeConsumer.producerIndexOfThis[idx]); + } + } + if (activeConsumer.producerNode[idx] !== node) { + activeConsumer.producerNode[idx] = node; + activeConsumer.producerIndexOfThis[idx] = consumerIsLive(activeConsumer) + ? producerAddLiveConsumer(node, activeConsumer, idx) + : 0; + } + activeConsumer.producerLastReadVersion[idx] = node.version; +} +function producerIncrementEpoch() { + epoch++; +} +function producerUpdateValueVersion(node) { + if (!node.dirty && node.lastCleanEpoch === epoch) return; + if (!node.producerMustRecompute(node) && !consumerPollProducersForChange(node)) { + node.dirty = false; + node.lastCleanEpoch = epoch; + return; + } + node.producerRecomputeValue(node); + node.dirty = false; + node.lastCleanEpoch = epoch; +} +function producerNotifyConsumers(node) { + if (node.liveConsumerNode === void 0) return; + const prev = inNotificationPhase; + inNotificationPhase = true; + try { + for (const consumer of node.liveConsumerNode) if (!consumer.dirty) consumerMarkDirty(consumer); + } finally { + inNotificationPhase = prev; + } +} +function producerUpdatesAllowed() { + return (activeConsumer == null ? void 0 : activeConsumer.consumerAllowSignalWrites) !== false; +} +function consumerMarkDirty(node) { + var _a; + node.dirty = true; + producerNotifyConsumers(node); + (_a = node.consumerMarkedDirty) == null || _a.call(node.wrapper ?? node); +} +function consumerBeforeComputation(node) { + node && (node.nextProducerIndex = 0); + return setActiveConsumer(node); +} +function consumerAfterComputation(node, prevConsumer) { + setActiveConsumer(prevConsumer); + if ( + !node || + node.producerNode === void 0 || + node.producerIndexOfThis === void 0 || + node.producerLastReadVersion === void 0 + ) + return; + if (consumerIsLive(node)) + for (let i = node.nextProducerIndex; i < node.producerNode.length; i++) + producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); + while (node.producerNode.length > node.nextProducerIndex) { + node.producerNode.pop(); + node.producerLastReadVersion.pop(); + node.producerIndexOfThis.pop(); + } +} +function consumerPollProducersForChange(node) { + assertConsumerNode(node); + for (let i = 0; i < node.producerNode.length; i++) { + const producer = node.producerNode[i]; + const seenVersion = node.producerLastReadVersion[i]; + if (seenVersion !== producer.version) return true; + producerUpdateValueVersion(producer); + if (seenVersion !== producer.version) return true; + } + return false; +} +function producerAddLiveConsumer(node, consumer, indexOfThis) { + var _a; + assertProducerNode(node); + assertConsumerNode(node); + if (node.liveConsumerNode.length === 0) { + (_a = node.watched) == null || _a.call(node.wrapper); + for (let i = 0; i < node.producerNode.length; i++) + node.producerIndexOfThis[i] = producerAddLiveConsumer(node.producerNode[i], node, i); + } + node.liveConsumerIndexOfThis.push(indexOfThis); + return node.liveConsumerNode.push(consumer) - 1; +} +function producerRemoveLiveConsumerAtIndex(node, idx) { + var _a; + assertProducerNode(node); + assertConsumerNode(node); + if (typeof ngDevMode !== "undefined" && ngDevMode && idx >= node.liveConsumerNode.length) + throw new Error( + `Assertion error: active consumer index ${idx} is out of bounds of ${node.liveConsumerNode.length} consumers)`, + ); + if (node.liveConsumerNode.length === 1) { + (_a = node.unwatched) == null || _a.call(node.wrapper); + for (let i = 0; i < node.producerNode.length; i++) + producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); + } + const lastIdx = node.liveConsumerNode.length - 1; + node.liveConsumerNode[idx] = node.liveConsumerNode[lastIdx]; + node.liveConsumerIndexOfThis[idx] = node.liveConsumerIndexOfThis[lastIdx]; + node.liveConsumerNode.length--; + node.liveConsumerIndexOfThis.length--; + if (idx < node.liveConsumerNode.length) { + const idxProducer = node.liveConsumerIndexOfThis[idx]; + const consumer = node.liveConsumerNode[idx]; + assertConsumerNode(consumer); + consumer.producerIndexOfThis[idxProducer] = idx; + } +} +function consumerIsLive(node) { + var _a; + return ( + node.consumerIsAlwaysLive || + (((_a = node == null ? void 0 : node.liveConsumerNode) == null ? void 0 : _a.length) ?? 0) > 0 + ); +} +function assertConsumerNode(node) { + node.producerNode ?? (node.producerNode = []); + node.producerIndexOfThis ?? (node.producerIndexOfThis = []); + node.producerLastReadVersion ?? (node.producerLastReadVersion = []); +} +function assertProducerNode(node) { + node.liveConsumerNode ?? (node.liveConsumerNode = []); + node.liveConsumerIndexOfThis ?? (node.liveConsumerIndexOfThis = []); +} +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +function computedGet(node) { + producerUpdateValueVersion(node); + producerAccessed(node); + if (node.value === ERRORED) throw node.error; + return node.value; +} +function createComputed(computation) { + const node = Object.create(COMPUTED_NODE); + node.computation = computation; + const computed = () => computedGet(node); + computed[SIGNAL] = node; + return computed; +} +const UNSET = /* @__PURE__ */ Symbol("UNSET"); +const COMPUTING = /* @__PURE__ */ Symbol("COMPUTING"); +const ERRORED = /* @__PURE__ */ Symbol("ERRORED"); +const COMPUTED_NODE = { + ...REACTIVE_NODE, + value: UNSET, + dirty: true, + error: null, + equal: defaultEquals, + producerMustRecompute(node) { + return node.value === UNSET || node.value === COMPUTING; + }, + producerRecomputeValue(node) { + if (node.value === COMPUTING) throw new Error("Detected cycle in computations."); + const oldValue = node.value; + node.value = COMPUTING; + const prevConsumer = consumerBeforeComputation(node); + let newValue; + let wasEqual = false; + try { + newValue = node.computation.call(node.wrapper); + wasEqual = + oldValue !== UNSET && + oldValue !== ERRORED && + node.equal.call(node.wrapper, oldValue, newValue); + } catch (err) { + newValue = ERRORED; + node.error = err; + } finally { + consumerAfterComputation(node, prevConsumer); + } + if (wasEqual) { + node.value = oldValue; + return; + } + node.value = newValue; + node.version++; + }, +}; +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +function defaultThrowError() { + throw new Error(); +} +let throwInvalidWriteToSignalErrorFn = defaultThrowError; +function throwInvalidWriteToSignalError() { + throwInvalidWriteToSignalErrorFn(); +} +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +function createSignal(initialValue) { + const node = Object.create(SIGNAL_NODE); + node.value = initialValue; + const getter = () => { + producerAccessed(node); + return node.value; + }; + getter[SIGNAL] = node; + return getter; +} +function signalGetFn() { + producerAccessed(this); + return this.value; +} +function signalSetFn(node, newValue) { + if (!producerUpdatesAllowed()) throwInvalidWriteToSignalError(); + if (!node.equal.call(node.wrapper, node.value, newValue)) { + node.value = newValue; + signalValueChanged(node); + } +} +const SIGNAL_NODE = { + ...REACTIVE_NODE, + equal: defaultEquals, + value: void 0, +}; +function signalValueChanged(node) { + node.version++; + producerIncrementEpoch(); + producerNotifyConsumers(node); +} +/** + * @license + * Copyright 2024 Bloomberg Finance L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const NODE = Symbol("node"); +var Signal; +((Signal2) => { + var _a, _brand, _b, _brand2; + class State { + constructor(initialValue, options = {}) { + __privateAdd(this, _brand); + __publicField(this, _a); + const node = createSignal(initialValue)[SIGNAL]; + this[NODE] = node; + node.wrapper = this; + if (options) { + const equals = options.equals; + if (equals) node.equal = equals; + node.watched = options[Signal2.subtle.watched]; + node.unwatched = options[Signal2.subtle.unwatched]; + } + } + get() { + if (!(0, Signal2.isState)(this)) + throw new TypeError("Wrong receiver type for Signal.State.prototype.get"); + return signalGetFn.call(this[NODE]); + } + set(newValue) { + if (!(0, Signal2.isState)(this)) + throw new TypeError("Wrong receiver type for Signal.State.prototype.set"); + if (isInNotificationPhase()) + throw new Error("Writes to signals not permitted during Watcher callback"); + const ref = this[NODE]; + signalSetFn(ref, newValue); + } + } + _a = NODE; + _brand = /* @__PURE__ */ new WeakSet(); + Signal2.isState = (s) => typeof s === "object" && __privateIn(_brand, s); + Signal2.State = State; + class Computed { + constructor(computation, options) { + __privateAdd(this, _brand2); + __publicField(this, _b); + const node = createComputed(computation)[SIGNAL]; + node.consumerAllowSignalWrites = true; + this[NODE] = node; + node.wrapper = this; + if (options) { + const equals = options.equals; + if (equals) node.equal = equals; + node.watched = options[Signal2.subtle.watched]; + node.unwatched = options[Signal2.subtle.unwatched]; + } + } + get() { + if (!(0, Signal2.isComputed)(this)) + throw new TypeError("Wrong receiver type for Signal.Computed.prototype.get"); + return computedGet(this[NODE]); + } + } + _b = NODE; + _brand2 = /* @__PURE__ */ new WeakSet(); + Signal2.isComputed = (c) => typeof c === "object" && __privateIn(_brand2, c); + Signal2.Computed = Computed; + ((subtle2) => { + var _a2, _brand3, _assertSignals, assertSignals_fn; + function untrack(cb) { + let output; + let prevActiveConsumer = null; + try { + prevActiveConsumer = setActiveConsumer(null); + output = cb(); + } finally { + setActiveConsumer(prevActiveConsumer); + } + return output; + } + subtle2.untrack = untrack; + function introspectSources(sink) { + var _a3; + if (!(0, Signal2.isComputed)(sink) && !(0, Signal2.isWatcher)(sink)) + throw new TypeError("Called introspectSources without a Computed or Watcher argument"); + return ((_a3 = sink[NODE].producerNode) == null ? void 0 : _a3.map((n) => n.wrapper)) ?? []; + } + subtle2.introspectSources = introspectSources; + function introspectSinks(signal) { + var _a3; + if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) + throw new TypeError("Called introspectSinks without a Signal argument"); + return ( + ((_a3 = signal[NODE].liveConsumerNode) == null ? void 0 : _a3.map((n) => n.wrapper)) ?? [] + ); + } + subtle2.introspectSinks = introspectSinks; + function hasSinks(signal) { + if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) + throw new TypeError("Called hasSinks without a Signal argument"); + const liveConsumerNode = signal[NODE].liveConsumerNode; + if (!liveConsumerNode) return false; + return liveConsumerNode.length > 0; + } + subtle2.hasSinks = hasSinks; + function hasSources(signal) { + if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isWatcher)(signal)) + throw new TypeError("Called hasSources without a Computed or Watcher argument"); + const producerNode = signal[NODE].producerNode; + if (!producerNode) return false; + return producerNode.length > 0; + } + subtle2.hasSources = hasSources; + class Watcher { + constructor(notify) { + __privateAdd(this, _brand3); + __privateAdd(this, _assertSignals); + __publicField(this, _a2); + let node = Object.create(REACTIVE_NODE); + node.wrapper = this; + node.consumerMarkedDirty = notify; + node.consumerIsAlwaysLive = true; + node.consumerAllowSignalWrites = false; + node.producerNode = []; + this[NODE] = node; + } + watch(...signals) { + if (!(0, Signal2.isWatcher)(this)) + throw new TypeError("Called unwatch without Watcher receiver"); + __privateMethod(this, _assertSignals, assertSignals_fn).call(this, signals); + const node = this[NODE]; + node.dirty = false; + const prev = setActiveConsumer(node); + for (const signal of signals) producerAccessed(signal[NODE]); + setActiveConsumer(prev); + } + unwatch(...signals) { + if (!(0, Signal2.isWatcher)(this)) + throw new TypeError("Called unwatch without Watcher receiver"); + __privateMethod(this, _assertSignals, assertSignals_fn).call(this, signals); + const node = this[NODE]; + assertConsumerNode(node); + for (let i = node.producerNode.length - 1; i >= 0; i--) + if (signals.includes(node.producerNode[i].wrapper)) { + producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); + const lastIdx = node.producerNode.length - 1; + node.producerNode[i] = node.producerNode[lastIdx]; + node.producerIndexOfThis[i] = node.producerIndexOfThis[lastIdx]; + node.producerNode.length--; + node.producerIndexOfThis.length--; + node.nextProducerIndex--; + if (i < node.producerNode.length) { + const idxConsumer = node.producerIndexOfThis[i]; + const producer = node.producerNode[i]; + assertProducerNode(producer); + producer.liveConsumerIndexOfThis[idxConsumer] = i; + } + } + } + getPending() { + if (!(0, Signal2.isWatcher)(this)) + throw new TypeError("Called getPending without Watcher receiver"); + return this[NODE].producerNode.filter((n) => n.dirty).map((n) => n.wrapper); + } + } + _a2 = NODE; + _brand3 = /* @__PURE__ */ new WeakSet(); + _assertSignals = /* @__PURE__ */ new WeakSet(); + assertSignals_fn = function (signals) { + for (const signal of signals) + if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) + throw new TypeError("Called watch/unwatch without a Computed or State argument"); + }; + Signal2.isWatcher = (w) => __privateIn(_brand3, w); + subtle2.Watcher = Watcher; + function currentComputed() { + var _a3; + return (_a3 = getActiveConsumer()) == null ? void 0 : _a3.wrapper; + } + subtle2.currentComputed = currentComputed; + subtle2.watched = Symbol("watched"); + subtle2.unwatched = Symbol("unwatched"); + })(Signal2.subtle || (Signal2.subtle = {})); +})(Signal || (Signal = {})); +/** + * equality check here is always false so that we can dirty the storage + * via setting to _anything_ + * + * + * This is for a pattern where we don't *directly* use signals to back the values used in collections + * so that instanceof checks and getters and other native features "just work" without having + * to do nested proxying. + * + * (though, see deep.ts for nested / deep behavior) + */ +const createStorage = (initial = null) => new Signal.State(initial, { equals: () => false }); +const ARRAY_GETTER_METHODS = new Set([ + Symbol.iterator, + "concat", + "entries", + "every", + "filter", + "find", + "findIndex", + "flat", + "flatMap", + "forEach", + "includes", + "indexOf", + "join", + "keys", + "lastIndexOf", + "map", + "reduce", + "reduceRight", + "slice", + "some", + "values", +]); +const ARRAY_WRITE_THEN_READ_METHODS = new Set(["fill", "push", "unshift"]); +function convertToInt(prop) { + if (typeof prop === "symbol") return null; + const num = Number(prop); + if (isNaN(num)) return null; + return num % 1 === 0 ? num : null; +} +var SignalArray = class SignalArray { + /** + * Creates an array from an iterable object. + * @param iterable An iterable object to convert to an array. + */ + /** + * Creates an array from an iterable object. + * @param iterable An iterable object to convert to an array. + * @param mapfn A mapping function to call on every element of the array. + * @param thisArg Value of 'this' used to invoke the mapfn. + */ + static from(iterable, mapfn, thisArg) { + return mapfn + ? new SignalArray(Array.from(iterable, mapfn, thisArg)) + : new SignalArray(Array.from(iterable)); + } + static of(...arr) { + return new SignalArray(arr); + } + constructor(arr = []) { + let clone = arr.slice(); + let self = this; + let boundFns = /* @__PURE__ */ new Map(); + /** + Flag to track whether we have *just* intercepted a call to `.push()` or + `.unshift()`, since in those cases (and only those cases!) the `Array` + itself checks `.length` to return from the function call. + */ + let nativelyAccessingLengthFromPushOrUnshift = false; + return new Proxy(clone, { + get(target, prop) { + let index = convertToInt(prop); + if (index !== null) { + self.#readStorageFor(index); + self.#collection.get(); + return target[index]; + } + if (prop === "length") { + if (nativelyAccessingLengthFromPushOrUnshift) + nativelyAccessingLengthFromPushOrUnshift = false; + else self.#collection.get(); + return target[prop]; + } + if (ARRAY_WRITE_THEN_READ_METHODS.has(prop)) + nativelyAccessingLengthFromPushOrUnshift = true; + if (ARRAY_GETTER_METHODS.has(prop)) { + let fn = boundFns.get(prop); + if (fn === void 0) { + fn = (...args) => { + self.#collection.get(); + return target[prop](...args); + }; + boundFns.set(prop, fn); + } + return fn; + } + return target[prop]; + }, + set(target, prop, value) { + target[prop] = value; + let index = convertToInt(prop); + if (index !== null) { + self.#dirtyStorageFor(index); + self.#collection.set(null); + } else if (prop === "length") self.#collection.set(null); + return true; + }, + getPrototypeOf() { + return SignalArray.prototype; + }, + }); + } + #collection = createStorage(); + #storages = /* @__PURE__ */ new Map(); + #readStorageFor(index) { + let storage = this.#storages.get(index); + if (storage === void 0) { + storage = createStorage(); + this.#storages.set(index, storage); + } + storage.get(); + } + #dirtyStorageFor(index) { + const storage = this.#storages.get(index); + if (storage) storage.set(null); + } +}; +Object.setPrototypeOf(SignalArray.prototype, Array.prototype); +var SignalMap = class { + collection = createStorage(); + storages = /* @__PURE__ */ new Map(); + vals; + readStorageFor(key) { + const { storages } = this; + let storage = storages.get(key); + if (storage === void 0) { + storage = createStorage(); + storages.set(key, storage); + } + storage.get(); + } + dirtyStorageFor(key) { + const storage = this.storages.get(key); + if (storage) storage.set(null); + } + constructor(existing) { + this.vals = existing ? new Map(existing) : /* @__PURE__ */ new Map(); + } + get(key) { + this.readStorageFor(key); + return this.vals.get(key); + } + has(key) { + this.readStorageFor(key); + return this.vals.has(key); + } + entries() { + this.collection.get(); + return this.vals.entries(); + } + keys() { + this.collection.get(); + return this.vals.keys(); + } + values() { + this.collection.get(); + return this.vals.values(); + } + forEach(fn) { + this.collection.get(); + this.vals.forEach(fn); + } + get size() { + this.collection.get(); + return this.vals.size; + } + [Symbol.iterator]() { + this.collection.get(); + return this.vals[Symbol.iterator](); + } + get [Symbol.toStringTag]() { + return this.vals[Symbol.toStringTag]; + } + set(key, value) { + this.dirtyStorageFor(key); + this.collection.set(null); + this.vals.set(key, value); + return this; + } + delete(key) { + this.dirtyStorageFor(key); + this.collection.set(null); + return this.vals.delete(key); + } + clear() { + this.storages.forEach((s) => s.set(null)); + this.collection.set(null); + this.vals.clear(); + } +}; +Object.setPrototypeOf(SignalMap.prototype, Map.prototype); +/** + * Create a reactive Object, backed by Signals, using a Proxy. + * This allows dynamic creation and deletion of signals using the object primitive + * APIs that most folks are familiar with -- the only difference is instantiation. + * ```js + * const obj = new SignalObject({ foo: 123 }); + * + * obj.foo // 123 + * obj.foo = 456 + * obj.foo // 456 + * obj.bar = 2 + * obj.bar // 2 + * ``` + */ +const SignalObject = class SignalObjectImpl { + static fromEntries(entries) { + return new SignalObjectImpl(Object.fromEntries(entries)); + } + #storages = /* @__PURE__ */ new Map(); + #collection = createStorage(); + constructor(obj = {}) { + let proto = Object.getPrototypeOf(obj); + let descs = Object.getOwnPropertyDescriptors(obj); + let clone = Object.create(proto); + for (let prop in descs) Object.defineProperty(clone, prop, descs[prop]); + let self = this; + return new Proxy(clone, { + get(target, prop, receiver) { + self.#readStorageFor(prop); + return Reflect.get(target, prop, receiver); + }, + has(target, prop) { + self.#readStorageFor(prop); + return prop in target; + }, + ownKeys(target) { + self.#collection.get(); + return Reflect.ownKeys(target); + }, + set(target, prop, value, receiver) { + let result = Reflect.set(target, prop, value, receiver); + self.#dirtyStorageFor(prop); + self.#dirtyCollection(); + return result; + }, + deleteProperty(target, prop) { + if (prop in target) { + delete target[prop]; + self.#dirtyStorageFor(prop); + self.#dirtyCollection(); + } + return true; + }, + getPrototypeOf() { + return SignalObjectImpl.prototype; + }, + }); + } + #readStorageFor(key) { + let storage = this.#storages.get(key); + if (storage === void 0) { + storage = createStorage(); + this.#storages.set(key, storage); + } + storage.get(); + } + #dirtyStorageFor(key) { + const storage = this.#storages.get(key); + if (storage) storage.set(null); + } + #dirtyCollection() { + this.#collection.set(null); + } +}; +var SignalSet = class { + collection = createStorage(); + storages = /* @__PURE__ */ new Map(); + vals; + storageFor(key) { + const storages = this.storages; + let storage = storages.get(key); + if (storage === void 0) { + storage = createStorage(); + storages.set(key, storage); + } + return storage; + } + dirtyStorageFor(key) { + const storage = this.storages.get(key); + if (storage) storage.set(null); + } + constructor(existing) { + this.vals = new Set(existing); + } + has(value) { + this.storageFor(value).get(); + return this.vals.has(value); + } + entries() { + this.collection.get(); + return this.vals.entries(); + } + keys() { + this.collection.get(); + return this.vals.keys(); + } + values() { + this.collection.get(); + return this.vals.values(); + } + forEach(fn) { + this.collection.get(); + this.vals.forEach(fn); + } + get size() { + this.collection.get(); + return this.vals.size; + } + [Symbol.iterator]() { + this.collection.get(); + return this.vals[Symbol.iterator](); + } + get [Symbol.toStringTag]() { + return this.vals[Symbol.toStringTag]; + } + add(value) { + this.dirtyStorageFor(value); + this.collection.set(null); + this.vals.add(value); + return this; + } + delete(value) { + this.dirtyStorageFor(value); + this.collection.set(null); + return this.vals.delete(value); + } + clear() { + this.storages.forEach((s) => s.set(null)); + this.collection.set(null); + this.vals.clear(); + } +}; +Object.setPrototypeOf(SignalSet.prototype, Set.prototype); +function create() { + return new A2uiMessageProcessor({ + arrayCtor: SignalArray, + mapCtor: SignalMap, + objCtor: SignalObject, + setCtor: SignalSet, + }); +} +const Data = { + createSignalA2uiMessageProcessor: create, + A2uiMessageProcessor, + Guards: guards_exports, +}; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const t$1 = (t) => (e, o) => { + void 0 !== o + ? o.addInitializer(() => { + customElements.define(t, e); + }) + : customElements.define(t, e); +}; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ const o$9 = { + attribute: !0, + type: String, + converter: u$3, + reflect: !1, + hasChanged: f$3, + }, + r$7 = (t = o$9, e, r) => { + const { kind: n, metadata: i } = r; + let s = globalThis.litPropertyMetadata.get(i); + if ( + (void 0 === s && globalThis.litPropertyMetadata.set(i, (s = /* @__PURE__ */ new Map())), + "setter" === n && ((t = Object.create(t)).wrapped = !0), + s.set(r.name, t), + "accessor" === n) + ) { + const { name: o } = r; + return { + set(r) { + const n = e.get.call(this); + (e.set.call(this, r), this.requestUpdate(o, n, t, !0, r)); + }, + init(e) { + return (void 0 !== e && this.C(o, void 0, t, e), e); + }, + }; + } + if ("setter" === n) { + const { name: o } = r; + return function (r) { + const n = this[o]; + (e.call(this, r), this.requestUpdate(o, n, t, !0, r)); + }; + } + throw Error("Unsupported decorator location: " + n); + }; +function n$6(t) { + return (e, o) => + "object" == typeof o + ? r$7(t, e, o) + : ((t, e, o) => { + const r = e.hasOwnProperty(o); + return ( + e.constructor.createProperty(o, t), r ? Object.getOwnPropertyDescriptor(e, o) : void 0 + ); + })(t, e, o); +} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ function r$6(r) { + return n$6({ + ...r, + state: !0, + attribute: !1, + }); +} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const e$6 = (e, t, c) => ( + (c.configurable = !0), + (c.enumerable = !0), + Reflect.decorate && "object" != typeof t && Object.defineProperty(e, t, c), + c +); +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ function e$5(e, r) { + return (n, s, i) => { + const o = (t) => t.renderRoot?.querySelector(e) ?? null; + if (r) { + const { get: e, set: r } = + "object" == typeof s + ? n + : (i ?? + (() => { + const t = Symbol(); + return { + get() { + return this[t]; + }, + set(e) { + this[t] = e; + }, + }; + })()); + return e$6(n, s, { + get() { + let t = e.call(this); + return ( + void 0 === t && ((t = o(this)), (null !== t || this.hasUpdated) && r.call(this, t)), t + ); + }, + }); + } + return e$6(n, s, { + get() { + return o(this); + }, + }); + }; +} +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ let i$2 = !1; +const s$1 = new Signal.subtle.Watcher(() => { + i$2 || + ((i$2 = !0), + queueMicrotask(() => { + i$2 = !1; + for (const t of s$1.getPending()) t.get(); + s$1.watch(); + })); + }), + h$3 = Symbol("SignalWatcherBrand"), + e$3 = new FinalizationRegistry((i) => { + i.unwatch(...Signal.subtle.introspectSources(i)); + }), + n$4 = /* @__PURE__ */ new WeakMap(); +function o$7(i) { + return !0 === i[h$3] + ? (console.warn("SignalWatcher should not be applied to the same class more than once."), i) + : class extends i { + constructor() { + (super(...arguments), + (this._$St = /* @__PURE__ */ new Map()), + (this._$So = new Signal.State(0)), + (this._$Si = !1)); + } + _$Sl() { + var t, i; + const s = [], + h = []; + this._$St.forEach((t, i) => { + ((null == t ? void 0 : t.beforeUpdate) ? s : h).push(i); + }); + const e = + null === (t = this.h) || void 0 === t + ? void 0 + : t.getPending().filter((t) => t !== this._$Su && !this._$St.has(t)); + (s.forEach((t) => t.get()), + null === (i = this._$Su) || void 0 === i || i.get(), + e.forEach((t) => t.get()), + h.forEach((t) => t.get())); + } + _$Sv() { + this.isUpdatePending || + queueMicrotask(() => { + this.isUpdatePending || this._$Sl(); + }); + } + _$S_() { + if (void 0 !== this.h) return; + this._$Su = new Signal.Computed(() => { + (this._$So.get(), super.performUpdate()); + }); + const i = (this.h = new Signal.subtle.Watcher(function () { + const t = n$4.get(this); + void 0 !== t && + (!1 === t._$Si && + (new Set(this.getPending()).has(t._$Su) ? t.requestUpdate() : t._$Sv()), + this.watch()); + })); + (n$4.set(i, this), + e$3.register(this, i), + i.watch(this._$Su), + i.watch(...Array.from(this._$St).map(([t]) => t))); + } + _$Sp() { + if (void 0 === this.h) return; + let i = !1; + (this.h.unwatch( + ...Signal.subtle.introspectSources(this.h).filter((t) => { + var s; + const h = + !0 !== (null === (s = this._$St.get(t)) || void 0 === s ? void 0 : s.manualDispose); + return (h && this._$St.delete(t), i || (i = !h), h); + }), + ), + i || ((this._$Su = void 0), (this.h = void 0), this._$St.clear())); + } + updateEffect(i, s) { + var h; + this._$S_(); + const e = new Signal.Computed(() => { + i(); + }); + return ( + this.h.watch(e), + this._$St.set(e, s), + null !== (h = null == s ? void 0 : s.beforeUpdate) && void 0 !== h && h + ? Signal.subtle.untrack(() => e.get()) + : this.updateComplete.then(() => Signal.subtle.untrack(() => e.get())), + () => { + (this._$St.delete(e), this.h.unwatch(e), !1 === this.isConnected && this._$Sp()); + } + ); + } + performUpdate() { + this.isUpdatePending && + (this._$S_(), + (this._$Si = !0), + this._$So.set(this._$So.get() + 1), + (this._$Si = !1), + this._$Sl()); + } + connectedCallback() { + (super.connectedCallback(), this.requestUpdate()); + } + disconnectedCallback() { + (super.disconnectedCallback(), + queueMicrotask(() => { + !1 === this.isConnected && this._$Sp(); + })); + } + }; +} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ const s = (i, t) => { + const e = i._$AN; + if (void 0 === e) return !1; + for (const i of e) (i._$AO?.(t, !1), s(i, t)); + return !0; + }, + o$6 = (i) => { + let t, e; + do { + if (void 0 === (t = i._$AM)) break; + ((e = t._$AN), e.delete(i), (i = t)); + } while (0 === e?.size); + }, + r$3 = (i) => { + for (let t; (t = i._$AM); i = t) { + let e = t._$AN; + if (void 0 === e) t._$AN = e = /* @__PURE__ */ new Set(); + else if (e.has(i)) break; + (e.add(i), c(t)); + } + }; +function h$2(i) { + void 0 !== this._$AN ? (o$6(this), (this._$AM = i), r$3(this)) : (this._$AM = i); +} +function n$3(i, t = !1, e = 0) { + const r = this._$AH, + h = this._$AN; + if (void 0 !== h && 0 !== h.size) + if (t) + if (Array.isArray(r)) for (let i = e; i < r.length; i++) (s(r[i], !1), o$6(r[i])); + else null != r && (s(r, !1), o$6(r)); + else s(this, i); +} +const c = (i) => { + i.type == t$4.CHILD && ((i._$AP ??= n$3), (i._$AQ ??= h$2)); +}; +var f = class extends i$5 { + constructor() { + (super(...arguments), (this._$AN = void 0)); + } + _$AT(i, t, e) { + (super._$AT(i, t, e), r$3(this), (this.isConnected = i._$AU)); + } + _$AO(i, t = !0) { + (i !== this.isConnected && + ((this.isConnected = i), i ? this.reconnected?.() : this.disconnected?.()), + t && (s(this, i), o$6(this))); + } + setValue(t) { + if (r$8(this._$Ct)) this._$Ct._$AI(t, this); + else { + const i = [...this._$Ct._$AH]; + ((i[this._$Ci] = t), this._$Ct._$AI(i, this, 0)); + } + } + disconnected() {} + reconnected() {} +}; +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +let o$5 = !1; +const n$2 = new Signal.subtle.Watcher(async () => { + o$5 || + ((o$5 = !0), + queueMicrotask(() => { + o$5 = !1; + for (const i of n$2.getPending()) i.get(); + n$2.watch(); + })); +}); +var r$2 = class extends f { + _$S_() { + var i, t; + void 0 === this._$Sm && + ((this._$Sj = new Signal.Computed(() => { + var i; + const t = null === (i = this._$SW) || void 0 === i ? void 0 : i.get(); + return (this.setValue(t), t); + })), + (this._$Sm = + null !== (t = null === (i = this._$Sk) || void 0 === i ? void 0 : i.h) && void 0 !== t + ? t + : n$2), + this._$Sm.watch(this._$Sj), + Signal.subtle.untrack(() => { + var i; + return null === (i = this._$Sj) || void 0 === i ? void 0 : i.get(); + })); + } + _$Sp() { + void 0 !== this._$Sm && (this._$Sm.unwatch(this._$SW), (this._$Sm = void 0)); + } + render(i) { + return Signal.subtle.untrack(() => i.get()); + } + update(i, [t]) { + var o, n; + return ( + (null !== (o = this._$Sk) && void 0 !== o) || + (this._$Sk = null === (n = i.options) || void 0 === n ? void 0 : n.host), + t !== this._$SW && void 0 !== this._$SW && this._$Sp(), + (this._$SW = t), + this._$S_(), + Signal.subtle.untrack(() => this._$SW.get()) + ); + } + disconnected() { + this._$Sp(); + } + reconnected() { + this._$S_(); + } +}; +const h$1 = e$10(r$2), + m = + (o) => + (t, ...m) => + o( + t, + ...m.map((o) => (o instanceof Signal.State || o instanceof Signal.Computed ? h$1(o) : o)), + ); +m(b); +m(w); +Signal.State; +Signal.Computed; +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +function* o$3(o, f) { + if (void 0 !== o) { + let i = 0; + for (const t of o) yield f(t, i++); + } +} +let pending = false; +let watcher = new Signal.subtle.Watcher(() => { + if (!pending) { + pending = true; + queueMicrotask(() => { + pending = false; + flushPending(); + }); + } +}); +function flushPending() { + for (const signal of watcher.getPending()) signal.get(); + watcher.watch(); +} +/** + * ⚠️ WARNING: Nothing unwatches ⚠️ + * This will produce a memory leak. + */ +function effect(cb) { + let c = new Signal.Computed(() => cb()); + watcher.watch(c); + c.get(); + return () => { + watcher.unwatch(c); + }; +} +const themeContext = n$7("A2UITheme"); +const structuralStyles = r$11(structuralStyles$1); +var ComponentRegistry = class { + constructor() { + this.registry = /* @__PURE__ */ new Map(); + } + register(typeName, constructor, tagName) { + if (!/^[a-zA-Z0-9]+$/.test(typeName)) + throw new Error(`[Registry] Invalid typeName '${typeName}'. Must be alphanumeric.`); + this.registry.set(typeName, constructor); + const actualTagName = tagName || `a2ui-custom-${typeName.toLowerCase()}`; + const existingName = customElements.getName(constructor); + if (existingName) { + if (existingName !== actualTagName) + throw new Error( + `Component ${typeName} is already registered as ${existingName}, but requested as ${actualTagName}.`, + ); + return; + } + if (!customElements.get(actualTagName)) customElements.define(actualTagName, constructor); + } + get(typeName) { + return this.registry.get(typeName); + } +}; +const componentRegistry = new ComponentRegistry(); +var __runInitializers$19 = function (thisArg, initializers, value) { + var useValue = arguments.length > 2; + for (var i = 0; i < initializers.length; i++) + value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg); + return useValue ? value : void 0; +}; +var __esDecorate$19 = function ( + ctor, + descriptorIn, + decorators, + contextIn, + initializers, + extraInitializers, +) { + function accept(f) { + if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); + return f; + } + var kind = contextIn.kind, + key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; + var target = !descriptorIn && ctor ? (contextIn["static"] ? ctor : ctor.prototype) : null; + var descriptor = + descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); + var _, + done = false; + for (var i = decorators.length - 1; i >= 0; i--) { + var context = {}; + for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p]; + for (var p in contextIn.access) context.access[p] = contextIn.access[p]; + context.addInitializer = function (f) { + if (done) throw new TypeError("Cannot add initializers after decoration has completed"); + extraInitializers.push(accept(f || null)); + }; + var result = (0, decorators[i])( + kind === "accessor" + ? { + get: descriptor.get, + set: descriptor.set, + } + : descriptor[key], + context, + ); + if (kind === "accessor") { + if (result === void 0) continue; + if (result === null || typeof result !== "object") throw new TypeError("Object expected"); + if ((_ = accept(result.get))) descriptor.get = _; + if ((_ = accept(result.set))) descriptor.set = _; + if ((_ = accept(result.init))) initializers.unshift(_); + } else if ((_ = accept(result))) + if (kind === "field") initializers.unshift(_); + else descriptor[key] = _; + } + if (target) Object.defineProperty(target, contextIn.name, descriptor); + done = true; +}; +let Root = (() => { + let _classDecorators = [t$1("a2ui-root")]; + let _classDescriptor; + let _classExtraInitializers = []; + let _classThis; + let _classSuper = o$7(i$6); + let _instanceExtraInitializers = []; + let _surfaceId_decorators; + let _surfaceId_initializers = []; + let _surfaceId_extraInitializers = []; + let _component_decorators; + let _component_initializers = []; + let _component_extraInitializers = []; + let _theme_decorators; + let _theme_initializers = []; + let _theme_extraInitializers = []; + let _childComponents_decorators; + let _childComponents_initializers = []; + let _childComponents_extraInitializers = []; + let _processor_decorators; + let _processor_initializers = []; + let _processor_extraInitializers = []; + let _dataContextPath_decorators; + let _dataContextPath_initializers = []; + let _dataContextPath_extraInitializers = []; + let _enableCustomElements_decorators; + let _enableCustomElements_initializers = []; + let _enableCustomElements_extraInitializers = []; + let _set_weight_decorators; + var Root = class extends _classSuper { + static { + _classThis = this; + } + static { + const _metadata = + typeof Symbol === "function" && Symbol.metadata + ? Object.create(_classSuper[Symbol.metadata] ?? null) + : void 0; + _surfaceId_decorators = [n$6()]; + _component_decorators = [n$6()]; + _theme_decorators = [c$1({ context: themeContext })]; + _childComponents_decorators = [n$6({ attribute: false })]; + _processor_decorators = [n$6({ attribute: false })]; + _dataContextPath_decorators = [n$6()]; + _enableCustomElements_decorators = [n$6()]; + _set_weight_decorators = [n$6()]; + __esDecorate$19( + this, + null, + _surfaceId_decorators, + { + kind: "accessor", + name: "surfaceId", + static: false, + private: false, + access: { + has: (obj) => "surfaceId" in obj, + get: (obj) => obj.surfaceId, + set: (obj, value) => { + obj.surfaceId = value; + }, + }, + metadata: _metadata, + }, + _surfaceId_initializers, + _surfaceId_extraInitializers, + ); + __esDecorate$19( + this, + null, + _component_decorators, + { + kind: "accessor", + name: "component", + static: false, + private: false, + access: { + has: (obj) => "component" in obj, + get: (obj) => obj.component, + set: (obj, value) => { + obj.component = value; + }, + }, + metadata: _metadata, + }, + _component_initializers, + _component_extraInitializers, + ); + __esDecorate$19( + this, + null, + _theme_decorators, + { + kind: "accessor", + name: "theme", + static: false, + private: false, + access: { + has: (obj) => "theme" in obj, + get: (obj) => obj.theme, + set: (obj, value) => { + obj.theme = value; + }, + }, + metadata: _metadata, + }, + _theme_initializers, + _theme_extraInitializers, + ); + __esDecorate$19( + this, + null, + _childComponents_decorators, + { + kind: "accessor", + name: "childComponents", + static: false, + private: false, + access: { + has: (obj) => "childComponents" in obj, + get: (obj) => obj.childComponents, + set: (obj, value) => { + obj.childComponents = value; + }, + }, + metadata: _metadata, + }, + _childComponents_initializers, + _childComponents_extraInitializers, + ); + __esDecorate$19( + this, + null, + _processor_decorators, + { + kind: "accessor", + name: "processor", + static: false, + private: false, + access: { + has: (obj) => "processor" in obj, + get: (obj) => obj.processor, + set: (obj, value) => { + obj.processor = value; + }, + }, + metadata: _metadata, + }, + _processor_initializers, + _processor_extraInitializers, + ); + __esDecorate$19( + this, + null, + _dataContextPath_decorators, + { + kind: "accessor", + name: "dataContextPath", + static: false, + private: false, + access: { + has: (obj) => "dataContextPath" in obj, + get: (obj) => obj.dataContextPath, + set: (obj, value) => { + obj.dataContextPath = value; + }, + }, + metadata: _metadata, + }, + _dataContextPath_initializers, + _dataContextPath_extraInitializers, + ); + __esDecorate$19( + this, + null, + _enableCustomElements_decorators, + { + kind: "accessor", + name: "enableCustomElements", + static: false, + private: false, + access: { + has: (obj) => "enableCustomElements" in obj, + get: (obj) => obj.enableCustomElements, + set: (obj, value) => { + obj.enableCustomElements = value; + }, + }, + metadata: _metadata, + }, + _enableCustomElements_initializers, + _enableCustomElements_extraInitializers, + ); + __esDecorate$19( + this, + null, + _set_weight_decorators, + { + kind: "setter", + name: "weight", + static: false, + private: false, + access: { + has: (obj) => "weight" in obj, + set: (obj, value) => { + obj.weight = value; + }, + }, + metadata: _metadata, + }, + null, + _instanceExtraInitializers, + ); + __esDecorate$19( + null, + (_classDescriptor = { value: _classThis }), + _classDecorators, + { + kind: "class", + name: _classThis.name, + metadata: _metadata, + }, + null, + _classExtraInitializers, + ); + Root = _classThis = _classDescriptor.value; + if (_metadata) + Object.defineProperty(_classThis, Symbol.metadata, { + enumerable: true, + configurable: true, + writable: true, + value: _metadata, + }); + } + #surfaceId_accessor_storage = + (__runInitializers$19(this, _instanceExtraInitializers), + __runInitializers$19(this, _surfaceId_initializers, null)); + get surfaceId() { + return this.#surfaceId_accessor_storage; + } + set surfaceId(value) { + this.#surfaceId_accessor_storage = value; + } + #component_accessor_storage = + (__runInitializers$19(this, _surfaceId_extraInitializers), + __runInitializers$19(this, _component_initializers, null)); + get component() { + return this.#component_accessor_storage; + } + set component(value) { + this.#component_accessor_storage = value; + } + #theme_accessor_storage = + (__runInitializers$19(this, _component_extraInitializers), + __runInitializers$19(this, _theme_initializers, void 0)); + get theme() { + return this.#theme_accessor_storage; + } + set theme(value) { + this.#theme_accessor_storage = value; + } + #childComponents_accessor_storage = + (__runInitializers$19(this, _theme_extraInitializers), + __runInitializers$19(this, _childComponents_initializers, null)); + get childComponents() { + return this.#childComponents_accessor_storage; + } + set childComponents(value) { + this.#childComponents_accessor_storage = value; + } + #processor_accessor_storage = + (__runInitializers$19(this, _childComponents_extraInitializers), + __runInitializers$19(this, _processor_initializers, null)); + get processor() { + return this.#processor_accessor_storage; + } + set processor(value) { + this.#processor_accessor_storage = value; + } + #dataContextPath_accessor_storage = + (__runInitializers$19(this, _processor_extraInitializers), + __runInitializers$19(this, _dataContextPath_initializers, "")); + get dataContextPath() { + return this.#dataContextPath_accessor_storage; + } + set dataContextPath(value) { + this.#dataContextPath_accessor_storage = value; + } + #enableCustomElements_accessor_storage = + (__runInitializers$19(this, _dataContextPath_extraInitializers), + __runInitializers$19(this, _enableCustomElements_initializers, false)); + get enableCustomElements() { + return this.#enableCustomElements_accessor_storage; + } + set enableCustomElements(value) { + this.#enableCustomElements_accessor_storage = value; + } + set weight(weight) { + this.#weight = weight; + this.style.setProperty("--weight", `${weight}`); + } + get weight() { + return this.#weight; + } + #weight = (__runInitializers$19(this, _enableCustomElements_extraInitializers), 1); + static { + this.styles = [ + structuralStyles, + i$9` + :host { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 80%; + } + `, + ]; + } + /** + * Holds the cleanup function for our effect. + * We need this to stop the effect when the component is disconnected. + */ + #lightDomEffectDisposer = null; + willUpdate(changedProperties) { + if (changedProperties.has("childComponents")) { + if (this.#lightDomEffectDisposer) this.#lightDomEffectDisposer(); + this.#lightDomEffectDisposer = effect(() => { + const allChildren = this.childComponents ?? null; + D(this.renderComponentTree(allChildren), this, { host: this }); + }); + } + } + /** + * Clean up the effect when the component is removed from the DOM. + */ + disconnectedCallback() { + super.disconnectedCallback(); + if (this.#lightDomEffectDisposer) this.#lightDomEffectDisposer(); + } + /** + * Turns the SignalMap into a renderable TemplateResult for Lit. + */ + renderComponentTree(components) { + if (!components) return A; + if (!Array.isArray(components)) return A; + return b` ${o$3(components, (component) => { + if (this.enableCustomElements) { + const elCtor = + componentRegistry.get(component.type) || customElements.get(component.type); + if (elCtor) { + const node = component; + const el = new elCtor(); + el.id = node.id; + if (node.slotName) el.slot = node.slotName; + el.component = node; + el.weight = node.weight ?? "initial"; + el.processor = this.processor; + el.surfaceId = this.surfaceId; + el.dataContextPath = node.dataContextPath ?? "/"; + for (const [prop, val] of Object.entries(component.properties)) el[prop] = val; + return b`${el}`; + } + } + switch (component.type) { + case "List": { + const node = component; + const childComponents = node.properties.children; + return b``; + } + case "Card": { + const node = component; + let childComponents = node.properties.children; + if (!childComponents && node.properties.child) + childComponents = [node.properties.child]; + return b``; + } + case "Column": { + const node = component; + return b``; + } + case "Row": { + const node = component; + return b``; + } + case "Image": { + const node = component; + return b``; + } + case "Icon": { + const node = component; + return b``; + } + case "AudioPlayer": { + const node = component; + return b``; + } + case "Button": { + const node = component; + return b``; + } + case "Text": { + const node = component; + return b``; + } + case "CheckBox": { + const node = component; + return b``; + } + case "DateTimeInput": { + const node = component; + return b``; + } + case "Divider": { + const node = component; + return b``; + } + case "MultipleChoice": { + const node = component; + return b``; + } + case "Slider": { + const node = component; + return b``; + } + case "TextField": { + const node = component; + return b``; + } + case "Video": { + const node = component; + return b``; + } + case "Tabs": { + const node = component; + const titles = []; + const childComponents = []; + if (node.properties.tabItems) + for (const item of node.properties.tabItems) { + titles.push(item.title); + childComponents.push(item.child); + } + return b``; + } + case "Modal": { + const node = component; + const childComponents = [node.properties.entryPointChild, node.properties.contentChild]; + node.properties.entryPointChild.slotName = "entry"; + return b``; + } + default: + return this.renderCustomComponent(component); + } + })}`; + } + renderCustomComponent(component) { + if (!this.enableCustomElements) return; + const node = component; + const elCtor = componentRegistry.get(component.type) || customElements.get(component.type); + if (!elCtor) return b`Unknown element ${component.type}`; + const el = new elCtor(); + el.id = node.id; + if (node.slotName) el.slot = node.slotName; + el.component = node; + el.weight = node.weight ?? "initial"; + el.processor = this.processor; + el.surfaceId = this.surfaceId; + el.dataContextPath = node.dataContextPath ?? "/"; + for (const [prop, val] of Object.entries(component.properties)) el[prop] = val; + return b`${el}`; + } + render() { + return b``; + } + static { + __runInitializers$19(_classThis, _classExtraInitializers); + } + }; + return _classThis; +})(); +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ const e$2 = e$10( + class extends i$5 { + constructor(t) { + if ((super(t), t.type !== t$4.ATTRIBUTE || "class" !== t.name || t.strings?.length > 2)) + throw Error( + "`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.", + ); + } + render(t) { + return ( + " " + + Object.keys(t) + .filter((s) => t[s]) + .join(" ") + + " " + ); + } + update(s, [i]) { + if (void 0 === this.st) { + ((this.st = /* @__PURE__ */ new Set()), + void 0 !== s.strings && + (this.nt = new Set( + s.strings + .join(" ") + .split(/\s/) + .filter((t) => "" !== t), + ))); + for (const t in i) i[t] && !this.nt?.has(t) && this.st.add(t); + return this.render(i); + } + const r = s.element.classList; + for (const t of this.st) t in i || (r.remove(t), this.st.delete(t)); + for (const t in i) { + const s = !!i[t]; + s === this.st.has(t) || + this.nt?.has(t) || + (s ? (r.add(t), this.st.add(t)) : (r.remove(t), this.st.delete(t))); + } + return E; + } + }, +); +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ const n$1 = "important", + i = " !" + n$1, + o$2 = e$10( + class extends i$5 { + constructor(t) { + if ((super(t), t.type !== t$4.ATTRIBUTE || "style" !== t.name || t.strings?.length > 2)) + throw Error( + "The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute.", + ); + } + render(t) { + return Object.keys(t).reduce((e, r) => { + const s = t[r]; + return null == s + ? e + : e + + `${(r = r.includes("-") ? r : r.replace(/(?:^(webkit|moz|ms|o)|)(?=[A-Z])/g, "-$&").toLowerCase())}:${s};`; + }, ""); + } + update(e, [r]) { + const { style: s } = e.element; + if (void 0 === this.ft) return ((this.ft = new Set(Object.keys(r))), this.render(r)); + for (const t of this.ft) + null == r[t] && + (this.ft.delete(t), t.includes("-") ? s.removeProperty(t) : (s[t] = null)); + for (const t in r) { + const e = r[t]; + if (null != e) { + this.ft.add(t); + const r = "string" == typeof e && e.endsWith(i); + t.includes("-") || r + ? s.setProperty(t, r ? e.slice(0, -11) : e, r ? n$1 : "") + : (s[t] = e); + } + } + return E; + } + }, + ); +var __esDecorate$18 = function ( + ctor, + descriptorIn, + decorators, + contextIn, + initializers, + extraInitializers, +) { + function accept(f) { + if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); + return f; + } + var kind = contextIn.kind, + key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; + var target = !descriptorIn && ctor ? (contextIn["static"] ? ctor : ctor.prototype) : null; + var descriptor = + descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); + var _, + done = false; + for (var i = decorators.length - 1; i >= 0; i--) { + var context = {}; + for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p]; + for (var p in contextIn.access) context.access[p] = contextIn.access[p]; + context.addInitializer = function (f) { + if (done) throw new TypeError("Cannot add initializers after decoration has completed"); + extraInitializers.push(accept(f || null)); + }; + var result = (0, decorators[i])( + kind === "accessor" + ? { + get: descriptor.get, + set: descriptor.set, + } + : descriptor[key], + context, + ); + if (kind === "accessor") { + if (result === void 0) continue; + if (result === null || typeof result !== "object") throw new TypeError("Object expected"); + if ((_ = accept(result.get))) descriptor.get = _; + if ((_ = accept(result.set))) descriptor.set = _; + if ((_ = accept(result.init))) initializers.unshift(_); + } else if ((_ = accept(result))) + if (kind === "field") initializers.unshift(_); + else descriptor[key] = _; + } + if (target) Object.defineProperty(target, contextIn.name, descriptor); + done = true; +}; +var __runInitializers$18 = function (thisArg, initializers, value) { + var useValue = arguments.length > 2; + for (var i = 0; i < initializers.length; i++) + value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg); + return useValue ? value : void 0; +}; +(() => { + let _classDecorators = [t$1("a2ui-audioplayer")]; + let _classDescriptor; + let _classExtraInitializers = []; + let _classThis; + let _classSuper = Root; + let _url_decorators; + let _url_initializers = []; + let _url_extraInitializers = []; + var Audio = class extends _classSuper { + static { + _classThis = this; + } + static { + const _metadata = + typeof Symbol === "function" && Symbol.metadata + ? Object.create(_classSuper[Symbol.metadata] ?? null) + : void 0; + _url_decorators = [n$6()]; + __esDecorate$18( + this, + null, + _url_decorators, + { + kind: "accessor", + name: "url", + static: false, + private: false, + access: { + has: (obj) => "url" in obj, + get: (obj) => obj.url, + set: (obj, value) => { + obj.url = value; + }, + }, + metadata: _metadata, + }, + _url_initializers, + _url_extraInitializers, + ); + __esDecorate$18( + null, + (_classDescriptor = { value: _classThis }), + _classDecorators, + { + kind: "class", + name: _classThis.name, + metadata: _metadata, + }, + null, + _classExtraInitializers, + ); + Audio = _classThis = _classDescriptor.value; + if (_metadata) + Object.defineProperty(_classThis, Symbol.metadata, { + enumerable: true, + configurable: true, + writable: true, + value: _metadata, + }); + } + #url_accessor_storage = __runInitializers$18(this, _url_initializers, null); + get url() { + return this.#url_accessor_storage; + } + set url(value) { + this.#url_accessor_storage = value; + } + static { + this.styles = [ + structuralStyles, + i$9` + * { + box-sizing: border-box; + } + + :host { + display: block; + flex: var(--weight); + min-height: 0; + overflow: auto; + } + + audio { + display: block; + width: 100%; + } + `, + ]; + } + #renderAudio() { + if (!this.url) return A; + if (this.url && typeof this.url === "object") { + if ("literalString" in this.url) return b`"; +}; +default_rules.code_block = function (tokens, idx, options, env, slf) { + const token = tokens[idx]; + return ( + "" + + escapeHtml(tokens[idx].content) + + "\n" + ); +}; +default_rules.fence = function (tokens, idx, options, env, slf) { + const token = tokens[idx]; + const info = token.info ? unescapeAll(token.info).trim() : ""; + let langName = ""; + let langAttrs = ""; + if (info) { + const arr = info.split(/(\s+)/g); + langName = arr[0]; + langAttrs = arr.slice(2).join(""); + } + let highlighted; + if (options.highlight) + highlighted = + options.highlight(token.content, langName, langAttrs) || escapeHtml(token.content); + else highlighted = escapeHtml(token.content); + if (highlighted.indexOf("${highlighted}
\n`; + } + return `
${highlighted}
\n`; +}; +default_rules.image = function (tokens, idx, options, env, slf) { + const token = tokens[idx]; + token.attrs[token.attrIndex("alt")][1] = slf.renderInlineAsText(token.children, options, env); + return slf.renderToken(tokens, idx, options); +}; +default_rules.hardbreak = function (tokens, idx, options) { + return options.xhtmlOut ? "
\n" : "
\n"; +}; +default_rules.softbreak = function (tokens, idx, options) { + return options.breaks ? (options.xhtmlOut ? "
\n" : "
\n") : "\n"; +}; +default_rules.text = function (tokens, idx) { + return escapeHtml(tokens[idx].content); +}; +default_rules.html_block = function (tokens, idx) { + return tokens[idx].content; +}; +default_rules.html_inline = function (tokens, idx) { + return tokens[idx].content; +}; +/** + * new Renderer() + * + * Creates new [[Renderer]] instance and fill [[Renderer#rules]] with defaults. + **/ +function Renderer() { + /** + * Renderer#rules -> Object + * + * Contains render rules for tokens. Can be updated and extended. + * + * ##### Example + * + * ```javascript + * var md = require('markdown-it')(); + * + * md.renderer.rules.strong_open = function () { return ''; }; + * md.renderer.rules.strong_close = function () { return ''; }; + * + * var result = md.renderInline(...); + * ``` + * + * Each rule is called as independent static function with fixed signature: + * + * ```javascript + * function my_token_render(tokens, idx, options, env, renderer) { + * // ... + * return renderedHTML; + * } + * ``` + * + * See [source code](https://github.com/markdown-it/markdown-it/blob/master/lib/renderer.mjs) + * for more details and examples. + **/ + this.rules = assign$1({}, default_rules); +} +/** + * Renderer.renderAttrs(token) -> String + * + * Render token attributes to string. + **/ +Renderer.prototype.renderAttrs = function renderAttrs(token) { + let i, l, result; + if (!token.attrs) return ""; + result = ""; + for (i = 0, l = token.attrs.length; i < l; i++) + result += " " + escapeHtml(token.attrs[i][0]) + '="' + escapeHtml(token.attrs[i][1]) + '"'; + return result; +}; +/** + * Renderer.renderToken(tokens, idx, options) -> String + * - tokens (Array): list of tokens + * - idx (Numbed): token index to render + * - options (Object): params of parser instance + * + * Default token renderer. Can be overriden by custom function + * in [[Renderer#rules]]. + **/ +Renderer.prototype.renderToken = function renderToken(tokens, idx, options) { + const token = tokens[idx]; + let result = ""; + if (token.hidden) return ""; + if (token.block && token.nesting !== -1 && idx && tokens[idx - 1].hidden) result += "\n"; + result += (token.nesting === -1 ? "\n" : ">"; + return result; +}; +/** + * Renderer.renderInline(tokens, options, env) -> String + * - tokens (Array): list on block tokens to render + * - options (Object): params of parser instance + * - env (Object): additional data from parsed input (references, for example) + * + * The same as [[Renderer.render]], but for single token of `inline` type. + **/ +Renderer.prototype.renderInline = function (tokens, options, env) { + let result = ""; + const rules = this.rules; + for (let i = 0, len = tokens.length; i < len; i++) { + const type = tokens[i].type; + if (typeof rules[type] !== "undefined") result += rules[type](tokens, i, options, env, this); + else result += this.renderToken(tokens, i, options); + } + return result; +}; +/** internal + * Renderer.renderInlineAsText(tokens, options, env) -> String + * - tokens (Array): list on block tokens to render + * - options (Object): params of parser instance + * - env (Object): additional data from parsed input (references, for example) + * + * Special kludge for image `alt` attributes to conform CommonMark spec. + * Don't try to use it! Spec requires to show `alt` content with stripped markup, + * instead of simple escaping. + **/ +Renderer.prototype.renderInlineAsText = function (tokens, options, env) { + let result = ""; + for (let i = 0, len = tokens.length; i < len; i++) + switch (tokens[i].type) { + case "text": + result += tokens[i].content; + break; + case "image": + result += this.renderInlineAsText(tokens[i].children, options, env); + break; + case "html_inline": + case "html_block": + result += tokens[i].content; + break; + case "softbreak": + case "hardbreak": + result += "\n"; + break; + default: + } + return result; +}; +/** + * Renderer.render(tokens, options, env) -> String + * - tokens (Array): list on block tokens to render + * - options (Object): params of parser instance + * - env (Object): additional data from parsed input (references, for example) + * + * Takes token stream and generates HTML. Probably, you will never need to call + * this method directly. + **/ +Renderer.prototype.render = function (tokens, options, env) { + let result = ""; + const rules = this.rules; + for (let i = 0, len = tokens.length; i < len; i++) { + const type = tokens[i].type; + if (type === "inline") result += this.renderInline(tokens[i].children, options, env); + else if (typeof rules[type] !== "undefined") + result += rules[type](tokens, i, options, env, this); + else result += this.renderToken(tokens, i, options, env); + } + return result; +}; +/** + * class Ruler + * + * Helper class, used by [[MarkdownIt#core]], [[MarkdownIt#block]] and + * [[MarkdownIt#inline]] to manage sequences of functions (rules): + * + * - keep rules in defined order + * - assign the name to each rule + * - enable/disable rules + * - add/replace rules + * - allow assign rules to additional named chains (in the same) + * - cacheing lists of active rules + * + * You will not need use this class directly until write plugins. For simple + * rules control use [[MarkdownIt.disable]], [[MarkdownIt.enable]] and + * [[MarkdownIt.use]]. + **/ +/** + * new Ruler() + **/ +function Ruler() { + this.__rules__ = []; + this.__cache__ = null; +} +Ruler.prototype.__find__ = function (name) { + for (let i = 0; i < this.__rules__.length; i++) if (this.__rules__[i].name === name) return i; + return -1; +}; +Ruler.prototype.__compile__ = function () { + const self = this; + const chains = [""]; + self.__rules__.forEach(function (rule) { + if (!rule.enabled) return; + rule.alt.forEach(function (altName) { + if (chains.indexOf(altName) < 0) chains.push(altName); + }); + }); + self.__cache__ = {}; + chains.forEach(function (chain) { + self.__cache__[chain] = []; + self.__rules__.forEach(function (rule) { + if (!rule.enabled) return; + if (chain && rule.alt.indexOf(chain) < 0) return; + self.__cache__[chain].push(rule.fn); + }); + }); +}; +/** + * Ruler.at(name, fn [, options]) + * - name (String): rule name to replace. + * - fn (Function): new rule function. + * - options (Object): new rule options (not mandatory). + * + * Replace rule by name with new function & options. Throws error if name not + * found. + * + * ##### Options: + * + * - __alt__ - array with names of "alternate" chains. + * + * ##### Example + * + * Replace existing typographer replacement rule with new one: + * + * ```javascript + * var md = require('markdown-it')(); + * + * md.core.ruler.at('replacements', function replace(state) { + * //... + * }); + * ``` + **/ +Ruler.prototype.at = function (name, fn, options) { + const index = this.__find__(name); + const opt = options || {}; + if (index === -1) throw new Error("Parser rule not found: " + name); + this.__rules__[index].fn = fn; + this.__rules__[index].alt = opt.alt || []; + this.__cache__ = null; +}; +/** + * Ruler.before(beforeName, ruleName, fn [, options]) + * - beforeName (String): new rule will be added before this one. + * - ruleName (String): name of added rule. + * - fn (Function): rule function. + * - options (Object): rule options (not mandatory). + * + * Add new rule to chain before one with given name. See also + * [[Ruler.after]], [[Ruler.push]]. + * + * ##### Options: + * + * - __alt__ - array with names of "alternate" chains. + * + * ##### Example + * + * ```javascript + * var md = require('markdown-it')(); + * + * md.block.ruler.before('paragraph', 'my_rule', function replace(state) { + * //... + * }); + * ``` + **/ +Ruler.prototype.before = function (beforeName, ruleName, fn, options) { + const index = this.__find__(beforeName); + const opt = options || {}; + if (index === -1) throw new Error("Parser rule not found: " + beforeName); + this.__rules__.splice(index, 0, { + name: ruleName, + enabled: true, + fn, + alt: opt.alt || [], + }); + this.__cache__ = null; +}; +/** + * Ruler.after(afterName, ruleName, fn [, options]) + * - afterName (String): new rule will be added after this one. + * - ruleName (String): name of added rule. + * - fn (Function): rule function. + * - options (Object): rule options (not mandatory). + * + * Add new rule to chain after one with given name. See also + * [[Ruler.before]], [[Ruler.push]]. + * + * ##### Options: + * + * - __alt__ - array with names of "alternate" chains. + * + * ##### Example + * + * ```javascript + * var md = require('markdown-it')(); + * + * md.inline.ruler.after('text', 'my_rule', function replace(state) { + * //... + * }); + * ``` + **/ +Ruler.prototype.after = function (afterName, ruleName, fn, options) { + const index = this.__find__(afterName); + const opt = options || {}; + if (index === -1) throw new Error("Parser rule not found: " + afterName); + this.__rules__.splice(index + 1, 0, { + name: ruleName, + enabled: true, + fn, + alt: opt.alt || [], + }); + this.__cache__ = null; +}; +/** + * Ruler.push(ruleName, fn [, options]) + * - ruleName (String): name of added rule. + * - fn (Function): rule function. + * - options (Object): rule options (not mandatory). + * + * Push new rule to the end of chain. See also + * [[Ruler.before]], [[Ruler.after]]. + * + * ##### Options: + * + * - __alt__ - array with names of "alternate" chains. + * + * ##### Example + * + * ```javascript + * var md = require('markdown-it')(); + * + * md.core.ruler.push('my_rule', function replace(state) { + * //... + * }); + * ``` + **/ +Ruler.prototype.push = function (ruleName, fn, options) { + const opt = options || {}; + this.__rules__.push({ + name: ruleName, + enabled: true, + fn, + alt: opt.alt || [], + }); + this.__cache__ = null; +}; +/** + * Ruler.enable(list [, ignoreInvalid]) -> Array + * - list (String|Array): list of rule names to enable. + * - ignoreInvalid (Boolean): set `true` to ignore errors when rule not found. + * + * Enable rules with given names. If any rule name not found - throw Error. + * Errors can be disabled by second param. + * + * Returns list of found rule names (if no exception happened). + * + * See also [[Ruler.disable]], [[Ruler.enableOnly]]. + **/ +Ruler.prototype.enable = function (list, ignoreInvalid) { + if (!Array.isArray(list)) list = [list]; + const result = []; + list.forEach(function (name) { + const idx = this.__find__(name); + if (idx < 0) { + if (ignoreInvalid) return; + throw new Error("Rules manager: invalid rule name " + name); + } + this.__rules__[idx].enabled = true; + result.push(name); + }, this); + this.__cache__ = null; + return result; +}; +/** + * Ruler.enableOnly(list [, ignoreInvalid]) + * - list (String|Array): list of rule names to enable (whitelist). + * - ignoreInvalid (Boolean): set `true` to ignore errors when rule not found. + * + * Enable rules with given names, and disable everything else. If any rule name + * not found - throw Error. Errors can be disabled by second param. + * + * See also [[Ruler.disable]], [[Ruler.enable]]. + **/ +Ruler.prototype.enableOnly = function (list, ignoreInvalid) { + if (!Array.isArray(list)) list = [list]; + this.__rules__.forEach(function (rule) { + rule.enabled = false; + }); + this.enable(list, ignoreInvalid); +}; +/** + * Ruler.disable(list [, ignoreInvalid]) -> Array + * - list (String|Array): list of rule names to disable. + * - ignoreInvalid (Boolean): set `true` to ignore errors when rule not found. + * + * Disable rules with given names. If any rule name not found - throw Error. + * Errors can be disabled by second param. + * + * Returns list of found rule names (if no exception happened). + * + * See also [[Ruler.enable]], [[Ruler.enableOnly]]. + **/ +Ruler.prototype.disable = function (list, ignoreInvalid) { + if (!Array.isArray(list)) list = [list]; + const result = []; + list.forEach(function (name) { + const idx = this.__find__(name); + if (idx < 0) { + if (ignoreInvalid) return; + throw new Error("Rules manager: invalid rule name " + name); + } + this.__rules__[idx].enabled = false; + result.push(name); + }, this); + this.__cache__ = null; + return result; +}; +/** + * Ruler.getRules(chainName) -> Array + * + * Return array of active functions (rules) for given chain name. It analyzes + * rules configuration, compiles caches if not exists and returns result. + * + * Default chain name is `''` (empty string). It can't be skipped. That's + * done intentionally, to keep signature monomorphic for high speed. + **/ +Ruler.prototype.getRules = function (chainName) { + if (this.__cache__ === null) this.__compile__(); + return this.__cache__[chainName] || []; +}; +/** + * class Token + **/ +/** + * new Token(type, tag, nesting) + * + * Create new token and fill passed properties. + **/ +function Token(type, tag, nesting) { + /** + * Token#type -> String + * + * Type of the token (string, e.g. "paragraph_open") + **/ + this.type = type; + /** + * Token#tag -> String + * + * html tag name, e.g. "p" + **/ + this.tag = tag; + /** + * Token#attrs -> Array + * + * Html attributes. Format: `[ [ name1, value1 ], [ name2, value2 ] ]` + **/ + this.attrs = null; + /** + * Token#map -> Array + * + * Source map info. Format: `[ line_begin, line_end ]` + **/ + this.map = null; + /** + * Token#nesting -> Number + * + * Level change (number in {-1, 0, 1} set), where: + * + * - `1` means the tag is opening + * - `0` means the tag is self-closing + * - `-1` means the tag is closing + **/ + this.nesting = nesting; + /** + * Token#level -> Number + * + * nesting level, the same as `state.level` + **/ + this.level = 0; + /** + * Token#children -> Array + * + * An array of child nodes (inline and img tokens) + **/ + this.children = null; + /** + * Token#content -> String + * + * In a case of self-closing tag (code, html, fence, etc.), + * it has contents of this tag. + **/ + this.content = ""; + /** + * Token#markup -> String + * + * '*' or '_' for emphasis, fence string for fence, etc. + **/ + this.markup = ""; + /** + * Token#info -> String + * + * Additional information: + * + * - Info string for "fence" tokens + * - The value "auto" for autolink "link_open" and "link_close" tokens + * - The string value of the item marker for ordered-list "list_item_open" tokens + **/ + this.info = ""; + /** + * Token#meta -> Object + * + * A place for plugins to store an arbitrary data + **/ + this.meta = null; + /** + * Token#block -> Boolean + * + * True for block-level tokens, false for inline tokens. + * Used in renderer to calculate line breaks + **/ + this.block = false; + /** + * Token#hidden -> Boolean + * + * If it's true, ignore this element when rendering. Used for tight lists + * to hide paragraphs. + **/ + this.hidden = false; +} +/** + * Token.attrIndex(name) -> Number + * + * Search attribute index by name. + **/ +Token.prototype.attrIndex = function attrIndex(name) { + if (!this.attrs) return -1; + const attrs = this.attrs; + for (let i = 0, len = attrs.length; i < len; i++) if (attrs[i][0] === name) return i; + return -1; +}; +/** + * Token.attrPush(attrData) + * + * Add `[ name, value ]` attribute to list. Init attrs if necessary + **/ +Token.prototype.attrPush = function attrPush(attrData) { + if (this.attrs) this.attrs.push(attrData); + else this.attrs = [attrData]; +}; +/** + * Token.attrSet(name, value) + * + * Set `name` attribute to `value`. Override old value if exists. + **/ +Token.prototype.attrSet = function attrSet(name, value) { + const idx = this.attrIndex(name); + const attrData = [name, value]; + if (idx < 0) this.attrPush(attrData); + else this.attrs[idx] = attrData; +}; +/** + * Token.attrGet(name) + * + * Get the value of attribute `name`, or null if it does not exist. + **/ +Token.prototype.attrGet = function attrGet(name) { + const idx = this.attrIndex(name); + let value = null; + if (idx >= 0) value = this.attrs[idx][1]; + return value; +}; +/** + * Token.attrJoin(name, value) + * + * Join value to existing attribute via space. Or create new attribute if not + * exists. Useful to operate with token classes. + **/ +Token.prototype.attrJoin = function attrJoin(name, value) { + const idx = this.attrIndex(name); + if (idx < 0) this.attrPush([name, value]); + else this.attrs[idx][1] = this.attrs[idx][1] + " " + value; +}; +function StateCore(src, md, env) { + this.src = src; + this.env = env; + this.tokens = []; + this.inlineMode = false; + this.md = md; +} +StateCore.prototype.Token = Token; +const NEWLINES_RE = /\r\n?|\n/g; +const NULL_RE = /\0/g; +function normalize(state) { + let str; + str = state.src.replace(NEWLINES_RE, "\n"); + str = str.replace(NULL_RE, "�"); + state.src = str; +} +function block(state) { + let token; + if (state.inlineMode) { + token = new state.Token("inline", "", 0); + token.content = state.src; + token.map = [0, 1]; + token.children = []; + state.tokens.push(token); + } else state.md.block.parse(state.src, state.md, state.env, state.tokens); +} +function inline(state) { + const tokens = state.tokens; + for (let i = 0, l = tokens.length; i < l; i++) { + const tok = tokens[i]; + if (tok.type === "inline") + state.md.inline.parse(tok.content, state.md, state.env, tok.children); + } +} +function isLinkOpen$1(str) { + return /^\s]/i.test(str); +} +function isLinkClose$1(str) { + return /^<\/a\s*>/i.test(str); +} +function linkify$1(state) { + const blockTokens = state.tokens; + if (!state.md.options.linkify) return; + for (let j = 0, l = blockTokens.length; j < l; j++) { + if (blockTokens[j].type !== "inline" || !state.md.linkify.pretest(blockTokens[j].content)) + continue; + let tokens = blockTokens[j].children; + let htmlLinkLevel = 0; + for (let i = tokens.length - 1; i >= 0; i--) { + const currentToken = tokens[i]; + if (currentToken.type === "link_close") { + i--; + while (tokens[i].level !== currentToken.level && tokens[i].type !== "link_open") i--; + continue; + } + if (currentToken.type === "html_inline") { + if (isLinkOpen$1(currentToken.content) && htmlLinkLevel > 0) htmlLinkLevel--; + if (isLinkClose$1(currentToken.content)) htmlLinkLevel++; + } + if (htmlLinkLevel > 0) continue; + if (currentToken.type === "text" && state.md.linkify.test(currentToken.content)) { + const text = currentToken.content; + let links = state.md.linkify.match(text); + const nodes = []; + let level = currentToken.level; + let lastPos = 0; + if ( + links.length > 0 && + links[0].index === 0 && + i > 0 && + tokens[i - 1].type === "text_special" + ) + links = links.slice(1); + for (let ln = 0; ln < links.length; ln++) { + const url = links[ln].url; + const fullUrl = state.md.normalizeLink(url); + if (!state.md.validateLink(fullUrl)) continue; + let urlText = links[ln].text; + if (!links[ln].schema) + urlText = state.md.normalizeLinkText("http://" + urlText).replace(/^http:\/\//, ""); + else if (links[ln].schema === "mailto:" && !/^mailto:/i.test(urlText)) + urlText = state.md.normalizeLinkText("mailto:" + urlText).replace(/^mailto:/, ""); + else urlText = state.md.normalizeLinkText(urlText); + const pos = links[ln].index; + if (pos > lastPos) { + const token = new state.Token("text", "", 0); + token.content = text.slice(lastPos, pos); + token.level = level; + nodes.push(token); + } + const token_o = new state.Token("link_open", "a", 1); + token_o.attrs = [["href", fullUrl]]; + token_o.level = level++; + token_o.markup = "linkify"; + token_o.info = "auto"; + nodes.push(token_o); + const token_t = new state.Token("text", "", 0); + token_t.content = urlText; + token_t.level = level; + nodes.push(token_t); + const token_c = new state.Token("link_close", "a", -1); + token_c.level = --level; + token_c.markup = "linkify"; + token_c.info = "auto"; + nodes.push(token_c); + lastPos = links[ln].lastIndex; + } + if (lastPos < text.length) { + const token = new state.Token("text", "", 0); + token.content = text.slice(lastPos); + token.level = level; + nodes.push(token); + } + blockTokens[j].children = tokens = arrayReplaceAt(tokens, i, nodes); + } + } + } +} +const RARE_RE = /\+-|\.\.|\?\?\?\?|!!!!|,,|--/; +const SCOPED_ABBR_TEST_RE = /\((c|tm|r)\)/i; +const SCOPED_ABBR_RE = /\((c|tm|r)\)/gi; +const SCOPED_ABBR = { + c: "©", + r: "®", + tm: "™", +}; +function replaceFn(match, name) { + return SCOPED_ABBR[name.toLowerCase()]; +} +function replace_scoped(inlineTokens) { + let inside_autolink = 0; + for (let i = inlineTokens.length - 1; i >= 0; i--) { + const token = inlineTokens[i]; + if (token.type === "text" && !inside_autolink) + token.content = token.content.replace(SCOPED_ABBR_RE, replaceFn); + if (token.type === "link_open" && token.info === "auto") inside_autolink--; + if (token.type === "link_close" && token.info === "auto") inside_autolink++; + } +} +function replace_rare(inlineTokens) { + let inside_autolink = 0; + for (let i = inlineTokens.length - 1; i >= 0; i--) { + const token = inlineTokens[i]; + if (token.type === "text" && !inside_autolink) { + if (RARE_RE.test(token.content)) + token.content = token.content + .replace(/\+-/g, "±") + .replace(/\.{2,}/g, "…") + .replace(/([?!])…/g, "$1..") + .replace(/([?!]){4,}/g, "$1$1$1") + .replace(/,{2,}/g, ",") + .replace(/(^|[^-])---(?=[^-]|$)/gm, "$1—") + .replace(/(^|\s)--(?=\s|$)/gm, "$1–") + .replace(/(^|[^-\s])--(?=[^-\s]|$)/gm, "$1–"); + } + if (token.type === "link_open" && token.info === "auto") inside_autolink--; + if (token.type === "link_close" && token.info === "auto") inside_autolink++; + } +} +function replace(state) { + let blkIdx; + if (!state.md.options.typographer) return; + for (blkIdx = state.tokens.length - 1; blkIdx >= 0; blkIdx--) { + if (state.tokens[blkIdx].type !== "inline") continue; + if (SCOPED_ABBR_TEST_RE.test(state.tokens[blkIdx].content)) + replace_scoped(state.tokens[blkIdx].children); + if (RARE_RE.test(state.tokens[blkIdx].content)) replace_rare(state.tokens[blkIdx].children); + } +} +const QUOTE_TEST_RE = /['"]/; +const QUOTE_RE = /['"]/g; +const APOSTROPHE = "’"; +function replaceAt(str, index, ch) { + return str.slice(0, index) + ch + str.slice(index + 1); +} +function process_inlines(tokens, state) { + let j; + const stack = []; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + const thisLevel = tokens[i].level; + for (j = stack.length - 1; j >= 0; j--) if (stack[j].level <= thisLevel) break; + stack.length = j + 1; + if (token.type !== "text") continue; + let text = token.content; + let pos = 0; + let max = text.length; + OUTER: while (pos < max) { + QUOTE_RE.lastIndex = pos; + const t = QUOTE_RE.exec(text); + if (!t) break; + let canOpen = true; + let canClose = true; + pos = t.index + 1; + const isSingle = t[0] === "'"; + let lastChar = 32; + if (t.index - 1 >= 0) lastChar = text.charCodeAt(t.index - 1); + else + for (j = i - 1; j >= 0; j--) { + if (tokens[j].type === "softbreak" || tokens[j].type === "hardbreak") break; + if (!tokens[j].content) continue; + lastChar = tokens[j].content.charCodeAt(tokens[j].content.length - 1); + break; + } + let nextChar = 32; + if (pos < max) nextChar = text.charCodeAt(pos); + else + for (j = i + 1; j < tokens.length; j++) { + if (tokens[j].type === "softbreak" || tokens[j].type === "hardbreak") break; + if (!tokens[j].content) continue; + nextChar = tokens[j].content.charCodeAt(0); + break; + } + const isLastPunctChar = + isMdAsciiPunct(lastChar) || isPunctChar(String.fromCharCode(lastChar)); + const isNextPunctChar = + isMdAsciiPunct(nextChar) || isPunctChar(String.fromCharCode(nextChar)); + const isLastWhiteSpace = isWhiteSpace(lastChar); + const isNextWhiteSpace = isWhiteSpace(nextChar); + if (isNextWhiteSpace) canOpen = false; + else if (isNextPunctChar) { + if (!(isLastWhiteSpace || isLastPunctChar)) canOpen = false; + } + if (isLastWhiteSpace) canClose = false; + else if (isLastPunctChar) { + if (!(isNextWhiteSpace || isNextPunctChar)) canClose = false; + } + if (nextChar === 34 && t[0] === '"') { + if (lastChar >= 48 && lastChar <= 57) canClose = canOpen = false; + } + if (canOpen && canClose) { + canOpen = isLastPunctChar; + canClose = isNextPunctChar; + } + if (!canOpen && !canClose) { + if (isSingle) token.content = replaceAt(token.content, t.index, APOSTROPHE); + continue; + } + if (canClose) + for (j = stack.length - 1; j >= 0; j--) { + let item = stack[j]; + if (stack[j].level < thisLevel) break; + if (item.single === isSingle && stack[j].level === thisLevel) { + item = stack[j]; + let openQuote; + let closeQuote; + if (isSingle) { + openQuote = state.md.options.quotes[2]; + closeQuote = state.md.options.quotes[3]; + } else { + openQuote = state.md.options.quotes[0]; + closeQuote = state.md.options.quotes[1]; + } + token.content = replaceAt(token.content, t.index, closeQuote); + tokens[item.token].content = replaceAt(tokens[item.token].content, item.pos, openQuote); + pos += closeQuote.length - 1; + if (item.token === i) pos += openQuote.length - 1; + text = token.content; + max = text.length; + stack.length = j; + continue OUTER; + } + } + if (canOpen) + stack.push({ + token: i, + pos: t.index, + single: isSingle, + level: thisLevel, + }); + else if (canClose && isSingle) token.content = replaceAt(token.content, t.index, APOSTROPHE); + } + } +} +function smartquotes(state) { + if (!state.md.options.typographer) return; + for (let blkIdx = state.tokens.length - 1; blkIdx >= 0; blkIdx--) { + if (state.tokens[blkIdx].type !== "inline" || !QUOTE_TEST_RE.test(state.tokens[blkIdx].content)) + continue; + process_inlines(state.tokens[blkIdx].children, state); + } +} +function text_join(state) { + let curr, last; + const blockTokens = state.tokens; + const l = blockTokens.length; + for (let j = 0; j < l; j++) { + if (blockTokens[j].type !== "inline") continue; + const tokens = blockTokens[j].children; + const max = tokens.length; + for (curr = 0; curr < max; curr++) + if (tokens[curr].type === "text_special") tokens[curr].type = "text"; + for (curr = last = 0; curr < max; curr++) + if (tokens[curr].type === "text" && curr + 1 < max && tokens[curr + 1].type === "text") + tokens[curr + 1].content = tokens[curr].content + tokens[curr + 1].content; + else { + if (curr !== last) tokens[last] = tokens[curr]; + last++; + } + if (curr !== last) tokens.length = last; + } +} +/** internal + * class Core + * + * Top-level rules executor. Glues block/inline parsers and does intermediate + * transformations. + **/ +const _rules$2 = [ + ["normalize", normalize], + ["block", block], + ["inline", inline], + ["linkify", linkify$1], + ["replacements", replace], + ["smartquotes", smartquotes], + ["text_join", text_join], +]; +/** + * new Core() + **/ +function Core() { + /** + * Core#ruler -> Ruler + * + * [[Ruler]] instance. Keep configuration of core rules. + **/ + this.ruler = new Ruler(); + for (let i = 0; i < _rules$2.length; i++) this.ruler.push(_rules$2[i][0], _rules$2[i][1]); +} +/** + * Core.process(state) + * + * Executes core chain rules. + **/ +Core.prototype.process = function (state) { + const rules = this.ruler.getRules(""); + for (let i = 0, l = rules.length; i < l; i++) rules[i](state); +}; +Core.prototype.State = StateCore; +function StateBlock(src, md, env, tokens) { + this.src = src; + this.md = md; + this.env = env; + this.tokens = tokens; + this.bMarks = []; + this.eMarks = []; + this.tShift = []; + this.sCount = []; + this.bsCount = []; + this.blkIndent = 0; + this.line = 0; + this.lineMax = 0; + this.tight = false; + this.ddIndent = -1; + this.listIndent = -1; + this.parentType = "root"; + this.level = 0; + const s = this.src; + for ( + let start = 0, pos = 0, indent = 0, offset = 0, len = s.length, indent_found = false; + pos < len; + pos++ + ) { + const ch = s.charCodeAt(pos); + if (!indent_found) + if (isSpace(ch)) { + indent++; + if (ch === 9) offset += 4 - (offset % 4); + else offset++; + continue; + } else indent_found = true; + if (ch === 10 || pos === len - 1) { + if (ch !== 10) pos++; + this.bMarks.push(start); + this.eMarks.push(pos); + this.tShift.push(indent); + this.sCount.push(offset); + this.bsCount.push(0); + indent_found = false; + indent = 0; + offset = 0; + start = pos + 1; + } + } + this.bMarks.push(s.length); + this.eMarks.push(s.length); + this.tShift.push(0); + this.sCount.push(0); + this.bsCount.push(0); + this.lineMax = this.bMarks.length - 1; +} +StateBlock.prototype.push = function (type, tag, nesting) { + const token = new Token(type, tag, nesting); + token.block = true; + if (nesting < 0) this.level--; + token.level = this.level; + if (nesting > 0) this.level++; + this.tokens.push(token); + return token; +}; +StateBlock.prototype.isEmpty = function isEmpty(line) { + return this.bMarks[line] + this.tShift[line] >= this.eMarks[line]; +}; +StateBlock.prototype.skipEmptyLines = function skipEmptyLines(from) { + for (let max = this.lineMax; from < max; from++) + if (this.bMarks[from] + this.tShift[from] < this.eMarks[from]) break; + return from; +}; +StateBlock.prototype.skipSpaces = function skipSpaces(pos) { + for (let max = this.src.length; pos < max; pos++) if (!isSpace(this.src.charCodeAt(pos))) break; + return pos; +}; +StateBlock.prototype.skipSpacesBack = function skipSpacesBack(pos, min) { + if (pos <= min) return pos; + while (pos > min) if (!isSpace(this.src.charCodeAt(--pos))) return pos + 1; + return pos; +}; +StateBlock.prototype.skipChars = function skipChars(pos, code) { + for (let max = this.src.length; pos < max; pos++) if (this.src.charCodeAt(pos) !== code) break; + return pos; +}; +StateBlock.prototype.skipCharsBack = function skipCharsBack(pos, code, min) { + if (pos <= min) return pos; + while (pos > min) if (code !== this.src.charCodeAt(--pos)) return pos + 1; + return pos; +}; +StateBlock.prototype.getLines = function getLines(begin, end, indent, keepLastLF) { + if (begin >= end) return ""; + const queue = new Array(end - begin); + for (let i = 0, line = begin; line < end; line++, i++) { + let lineIndent = 0; + const lineStart = this.bMarks[line]; + let first = lineStart; + let last; + if (line + 1 < end || keepLastLF) last = this.eMarks[line] + 1; + else last = this.eMarks[line]; + while (first < last && lineIndent < indent) { + const ch = this.src.charCodeAt(first); + if (isSpace(ch)) + if (ch === 9) lineIndent += 4 - ((lineIndent + this.bsCount[line]) % 4); + else lineIndent++; + else if (first - lineStart < this.tShift[line]) lineIndent++; + else break; + first++; + } + if (lineIndent > indent) + queue[i] = new Array(lineIndent - indent + 1).join(" ") + this.src.slice(first, last); + else queue[i] = this.src.slice(first, last); + } + return queue.join(""); +}; +StateBlock.prototype.Token = Token; +const MAX_AUTOCOMPLETED_CELLS = 65536; +function getLine(state, line) { + const pos = state.bMarks[line] + state.tShift[line]; + const max = state.eMarks[line]; + return state.src.slice(pos, max); +} +function escapedSplit(str) { + const result = []; + const max = str.length; + let pos = 0; + let ch = str.charCodeAt(pos); + let isEscaped = false; + let lastPos = 0; + let current = ""; + while (pos < max) { + if (ch === 124) + if (!isEscaped) { + result.push(current + str.substring(lastPos, pos)); + current = ""; + lastPos = pos + 1; + } else { + current += str.substring(lastPos, pos - 1); + lastPos = pos; + } + isEscaped = ch === 92; + pos++; + ch = str.charCodeAt(pos); + } + result.push(current + str.substring(lastPos)); + return result; +} +function table(state, startLine, endLine, silent) { + if (startLine + 2 > endLine) return false; + let nextLine = startLine + 1; + if (state.sCount[nextLine] < state.blkIndent) return false; + if (state.sCount[nextLine] - state.blkIndent >= 4) return false; + let pos = state.bMarks[nextLine] + state.tShift[nextLine]; + if (pos >= state.eMarks[nextLine]) return false; + const firstCh = state.src.charCodeAt(pos++); + if (firstCh !== 124 && firstCh !== 45 && firstCh !== 58) return false; + if (pos >= state.eMarks[nextLine]) return false; + const secondCh = state.src.charCodeAt(pos++); + if (secondCh !== 124 && secondCh !== 45 && secondCh !== 58 && !isSpace(secondCh)) return false; + if (firstCh === 45 && isSpace(secondCh)) return false; + while (pos < state.eMarks[nextLine]) { + const ch = state.src.charCodeAt(pos); + if (ch !== 124 && ch !== 45 && ch !== 58 && !isSpace(ch)) return false; + pos++; + } + let lineText = getLine(state, startLine + 1); + let columns = lineText.split("|"); + const aligns = []; + for (let i = 0; i < columns.length; i++) { + const t = columns[i].trim(); + if (!t) + if (i === 0 || i === columns.length - 1) continue; + else return false; + if (!/^:?-+:?$/.test(t)) return false; + if (t.charCodeAt(t.length - 1) === 58) aligns.push(t.charCodeAt(0) === 58 ? "center" : "right"); + else if (t.charCodeAt(0) === 58) aligns.push("left"); + else aligns.push(""); + } + lineText = getLine(state, startLine).trim(); + if (lineText.indexOf("|") === -1) return false; + if (state.sCount[startLine] - state.blkIndent >= 4) return false; + columns = escapedSplit(lineText); + if (columns.length && columns[0] === "") columns.shift(); + if (columns.length && columns[columns.length - 1] === "") columns.pop(); + const columnCount = columns.length; + if (columnCount === 0 || columnCount !== aligns.length) return false; + if (silent) return true; + const oldParentType = state.parentType; + state.parentType = "table"; + const terminatorRules = state.md.block.ruler.getRules("blockquote"); + const token_to = state.push("table_open", "table", 1); + const tableLines = [startLine, 0]; + token_to.map = tableLines; + const token_tho = state.push("thead_open", "thead", 1); + token_tho.map = [startLine, startLine + 1]; + const token_htro = state.push("tr_open", "tr", 1); + token_htro.map = [startLine, startLine + 1]; + for (let i = 0; i < columns.length; i++) { + const token_ho = state.push("th_open", "th", 1); + if (aligns[i]) token_ho.attrs = [["style", "text-align:" + aligns[i]]]; + const token_il = state.push("inline", "", 0); + token_il.content = columns[i].trim(); + token_il.children = []; + state.push("th_close", "th", -1); + } + state.push("tr_close", "tr", -1); + state.push("thead_close", "thead", -1); + let tbodyLines; + let autocompletedCells = 0; + for (nextLine = startLine + 2; nextLine < endLine; nextLine++) { + if (state.sCount[nextLine] < state.blkIndent) break; + let terminate = false; + for (let i = 0, l = terminatorRules.length; i < l; i++) + if (terminatorRules[i](state, nextLine, endLine, true)) { + terminate = true; + break; + } + if (terminate) break; + lineText = getLine(state, nextLine).trim(); + if (!lineText) break; + if (state.sCount[nextLine] - state.blkIndent >= 4) break; + columns = escapedSplit(lineText); + if (columns.length && columns[0] === "") columns.shift(); + if (columns.length && columns[columns.length - 1] === "") columns.pop(); + autocompletedCells += columnCount - columns.length; + if (autocompletedCells > MAX_AUTOCOMPLETED_CELLS) break; + if (nextLine === startLine + 2) { + const token_tbo = state.push("tbody_open", "tbody", 1); + token_tbo.map = tbodyLines = [startLine + 2, 0]; + } + const token_tro = state.push("tr_open", "tr", 1); + token_tro.map = [nextLine, nextLine + 1]; + for (let i = 0; i < columnCount; i++) { + const token_tdo = state.push("td_open", "td", 1); + if (aligns[i]) token_tdo.attrs = [["style", "text-align:" + aligns[i]]]; + const token_il = state.push("inline", "", 0); + token_il.content = columns[i] ? columns[i].trim() : ""; + token_il.children = []; + state.push("td_close", "td", -1); + } + state.push("tr_close", "tr", -1); + } + if (tbodyLines) { + state.push("tbody_close", "tbody", -1); + tbodyLines[1] = nextLine; + } + state.push("table_close", "table", -1); + tableLines[1] = nextLine; + state.parentType = oldParentType; + state.line = nextLine; + return true; +} +function code(state, startLine, endLine) { + if (state.sCount[startLine] - state.blkIndent < 4) return false; + let nextLine = startLine + 1; + let last = nextLine; + while (nextLine < endLine) { + if (state.isEmpty(nextLine)) { + nextLine++; + continue; + } + if (state.sCount[nextLine] - state.blkIndent >= 4) { + nextLine++; + last = nextLine; + continue; + } + break; + } + state.line = last; + const token = state.push("code_block", "code", 0); + token.content = state.getLines(startLine, last, 4 + state.blkIndent, false) + "\n"; + token.map = [startLine, state.line]; + return true; +} +function fence(state, startLine, endLine, silent) { + let pos = state.bMarks[startLine] + state.tShift[startLine]; + let max = state.eMarks[startLine]; + if (state.sCount[startLine] - state.blkIndent >= 4) return false; + if (pos + 3 > max) return false; + const marker = state.src.charCodeAt(pos); + if (marker !== 126 && marker !== 96) return false; + let mem = pos; + pos = state.skipChars(pos, marker); + let len = pos - mem; + if (len < 3) return false; + const markup = state.src.slice(mem, pos); + const params = state.src.slice(pos, max); + if (marker === 96) { + if (params.indexOf(String.fromCharCode(marker)) >= 0) return false; + } + if (silent) return true; + let nextLine = startLine; + let haveEndMarker = false; + for (;;) { + nextLine++; + if (nextLine >= endLine) break; + pos = mem = state.bMarks[nextLine] + state.tShift[nextLine]; + max = state.eMarks[nextLine]; + if (pos < max && state.sCount[nextLine] < state.blkIndent) break; + if (state.src.charCodeAt(pos) !== marker) continue; + if (state.sCount[nextLine] - state.blkIndent >= 4) continue; + pos = state.skipChars(pos, marker); + if (pos - mem < len) continue; + pos = state.skipSpaces(pos); + if (pos < max) continue; + haveEndMarker = true; + break; + } + len = state.sCount[startLine]; + state.line = nextLine + (haveEndMarker ? 1 : 0); + const token = state.push("fence", "code", 0); + token.info = params; + token.content = state.getLines(startLine + 1, nextLine, len, true); + token.markup = markup; + token.map = [startLine, state.line]; + return true; +} +function blockquote(state, startLine, endLine, silent) { + let pos = state.bMarks[startLine] + state.tShift[startLine]; + let max = state.eMarks[startLine]; + const oldLineMax = state.lineMax; + if (state.sCount[startLine] - state.blkIndent >= 4) return false; + if (state.src.charCodeAt(pos) !== 62) return false; + if (silent) return true; + const oldBMarks = []; + const oldBSCount = []; + const oldSCount = []; + const oldTShift = []; + const terminatorRules = state.md.block.ruler.getRules("blockquote"); + const oldParentType = state.parentType; + state.parentType = "blockquote"; + let lastLineEmpty = false; + let nextLine; + for (nextLine = startLine; nextLine < endLine; nextLine++) { + const isOutdented = state.sCount[nextLine] < state.blkIndent; + pos = state.bMarks[nextLine] + state.tShift[nextLine]; + max = state.eMarks[nextLine]; + if (pos >= max) break; + if (state.src.charCodeAt(pos++) === 62 && !isOutdented) { + let initial = state.sCount[nextLine] + 1; + let spaceAfterMarker; + let adjustTab; + if (state.src.charCodeAt(pos) === 32) { + pos++; + initial++; + adjustTab = false; + spaceAfterMarker = true; + } else if (state.src.charCodeAt(pos) === 9) { + spaceAfterMarker = true; + if ((state.bsCount[nextLine] + initial) % 4 === 3) { + pos++; + initial++; + adjustTab = false; + } else adjustTab = true; + } else spaceAfterMarker = false; + let offset = initial; + oldBMarks.push(state.bMarks[nextLine]); + state.bMarks[nextLine] = pos; + while (pos < max) { + const ch = state.src.charCodeAt(pos); + if (isSpace(ch)) + if (ch === 9) + offset += 4 - ((offset + state.bsCount[nextLine] + (adjustTab ? 1 : 0)) % 4); + else offset++; + else break; + pos++; + } + lastLineEmpty = pos >= max; + oldBSCount.push(state.bsCount[nextLine]); + state.bsCount[nextLine] = state.sCount[nextLine] + 1 + (spaceAfterMarker ? 1 : 0); + oldSCount.push(state.sCount[nextLine]); + state.sCount[nextLine] = offset - initial; + oldTShift.push(state.tShift[nextLine]); + state.tShift[nextLine] = pos - state.bMarks[nextLine]; + continue; + } + if (lastLineEmpty) break; + let terminate = false; + for (let i = 0, l = terminatorRules.length; i < l; i++) + if (terminatorRules[i](state, nextLine, endLine, true)) { + terminate = true; + break; + } + if (terminate) { + state.lineMax = nextLine; + if (state.blkIndent !== 0) { + oldBMarks.push(state.bMarks[nextLine]); + oldBSCount.push(state.bsCount[nextLine]); + oldTShift.push(state.tShift[nextLine]); + oldSCount.push(state.sCount[nextLine]); + state.sCount[nextLine] -= state.blkIndent; + } + break; + } + oldBMarks.push(state.bMarks[nextLine]); + oldBSCount.push(state.bsCount[nextLine]); + oldTShift.push(state.tShift[nextLine]); + oldSCount.push(state.sCount[nextLine]); + state.sCount[nextLine] = -1; + } + const oldIndent = state.blkIndent; + state.blkIndent = 0; + const token_o = state.push("blockquote_open", "blockquote", 1); + token_o.markup = ">"; + const lines = [startLine, 0]; + token_o.map = lines; + state.md.block.tokenize(state, startLine, nextLine); + const token_c = state.push("blockquote_close", "blockquote", -1); + token_c.markup = ">"; + state.lineMax = oldLineMax; + state.parentType = oldParentType; + lines[1] = state.line; + for (let i = 0; i < oldTShift.length; i++) { + state.bMarks[i + startLine] = oldBMarks[i]; + state.tShift[i + startLine] = oldTShift[i]; + state.sCount[i + startLine] = oldSCount[i]; + state.bsCount[i + startLine] = oldBSCount[i]; + } + state.blkIndent = oldIndent; + return true; +} +function hr(state, startLine, endLine, silent) { + const max = state.eMarks[startLine]; + if (state.sCount[startLine] - state.blkIndent >= 4) return false; + let pos = state.bMarks[startLine] + state.tShift[startLine]; + const marker = state.src.charCodeAt(pos++); + if (marker !== 42 && marker !== 45 && marker !== 95) return false; + let cnt = 1; + while (pos < max) { + const ch = state.src.charCodeAt(pos++); + if (ch !== marker && !isSpace(ch)) return false; + if (ch === marker) cnt++; + } + if (cnt < 3) return false; + if (silent) return true; + state.line = startLine + 1; + const token = state.push("hr", "hr", 0); + token.map = [startLine, state.line]; + token.markup = Array(cnt + 1).join(String.fromCharCode(marker)); + return true; +} +function skipBulletListMarker(state, startLine) { + const max = state.eMarks[startLine]; + let pos = state.bMarks[startLine] + state.tShift[startLine]; + const marker = state.src.charCodeAt(pos++); + if (marker !== 42 && marker !== 45 && marker !== 43) return -1; + if (pos < max) { + if (!isSpace(state.src.charCodeAt(pos))) return -1; + } + return pos; +} +function skipOrderedListMarker(state, startLine) { + const start = state.bMarks[startLine] + state.tShift[startLine]; + const max = state.eMarks[startLine]; + let pos = start; + if (pos + 1 >= max) return -1; + let ch = state.src.charCodeAt(pos++); + if (ch < 48 || ch > 57) return -1; + for (;;) { + if (pos >= max) return -1; + ch = state.src.charCodeAt(pos++); + if (ch >= 48 && ch <= 57) { + if (pos - start >= 10) return -1; + continue; + } + if (ch === 41 || ch === 46) break; + return -1; + } + if (pos < max) { + ch = state.src.charCodeAt(pos); + if (!isSpace(ch)) return -1; + } + return pos; +} +function markTightParagraphs(state, idx) { + const level = state.level + 2; + for (let i = idx + 2, l = state.tokens.length - 2; i < l; i++) + if (state.tokens[i].level === level && state.tokens[i].type === "paragraph_open") { + state.tokens[i + 2].hidden = true; + state.tokens[i].hidden = true; + i += 2; + } +} +function list(state, startLine, endLine, silent) { + let max, pos, start, token; + let nextLine = startLine; + let tight = true; + if (state.sCount[nextLine] - state.blkIndent >= 4) return false; + if ( + state.listIndent >= 0 && + state.sCount[nextLine] - state.listIndent >= 4 && + state.sCount[nextLine] < state.blkIndent + ) + return false; + let isTerminatingParagraph = false; + if (silent && state.parentType === "paragraph") { + if (state.sCount[nextLine] >= state.blkIndent) isTerminatingParagraph = true; + } + let isOrdered; + let markerValue; + let posAfterMarker; + if ((posAfterMarker = skipOrderedListMarker(state, nextLine)) >= 0) { + isOrdered = true; + start = state.bMarks[nextLine] + state.tShift[nextLine]; + markerValue = Number(state.src.slice(start, posAfterMarker - 1)); + if (isTerminatingParagraph && markerValue !== 1) return false; + } else if ((posAfterMarker = skipBulletListMarker(state, nextLine)) >= 0) isOrdered = false; + else return false; + if (isTerminatingParagraph) { + if (state.skipSpaces(posAfterMarker) >= state.eMarks[nextLine]) return false; + } + if (silent) return true; + const markerCharCode = state.src.charCodeAt(posAfterMarker - 1); + const listTokIdx = state.tokens.length; + if (isOrdered) { + token = state.push("ordered_list_open", "ol", 1); + if (markerValue !== 1) token.attrs = [["start", markerValue]]; + } else token = state.push("bullet_list_open", "ul", 1); + const listLines = [nextLine, 0]; + token.map = listLines; + token.markup = String.fromCharCode(markerCharCode); + let prevEmptyEnd = false; + const terminatorRules = state.md.block.ruler.getRules("list"); + const oldParentType = state.parentType; + state.parentType = "list"; + while (nextLine < endLine) { + pos = posAfterMarker; + max = state.eMarks[nextLine]; + const initial = + state.sCount[nextLine] + posAfterMarker - (state.bMarks[nextLine] + state.tShift[nextLine]); + let offset = initial; + while (pos < max) { + const ch = state.src.charCodeAt(pos); + if (ch === 9) offset += 4 - ((offset + state.bsCount[nextLine]) % 4); + else if (ch === 32) offset++; + else break; + pos++; + } + const contentStart = pos; + let indentAfterMarker; + if (contentStart >= max) indentAfterMarker = 1; + else indentAfterMarker = offset - initial; + if (indentAfterMarker > 4) indentAfterMarker = 1; + const indent = initial + indentAfterMarker; + token = state.push("list_item_open", "li", 1); + token.markup = String.fromCharCode(markerCharCode); + const itemLines = [nextLine, 0]; + token.map = itemLines; + if (isOrdered) token.info = state.src.slice(start, posAfterMarker - 1); + const oldTight = state.tight; + const oldTShift = state.tShift[nextLine]; + const oldSCount = state.sCount[nextLine]; + const oldListIndent = state.listIndent; + state.listIndent = state.blkIndent; + state.blkIndent = indent; + state.tight = true; + state.tShift[nextLine] = contentStart - state.bMarks[nextLine]; + state.sCount[nextLine] = offset; + if (contentStart >= max && state.isEmpty(nextLine + 1)) + state.line = Math.min(state.line + 2, endLine); + else state.md.block.tokenize(state, nextLine, endLine, true); + if (!state.tight || prevEmptyEnd) tight = false; + prevEmptyEnd = state.line - nextLine > 1 && state.isEmpty(state.line - 1); + state.blkIndent = state.listIndent; + state.listIndent = oldListIndent; + state.tShift[nextLine] = oldTShift; + state.sCount[nextLine] = oldSCount; + state.tight = oldTight; + token = state.push("list_item_close", "li", -1); + token.markup = String.fromCharCode(markerCharCode); + nextLine = state.line; + itemLines[1] = nextLine; + if (nextLine >= endLine) break; + if (state.sCount[nextLine] < state.blkIndent) break; + if (state.sCount[nextLine] - state.blkIndent >= 4) break; + let terminate = false; + for (let i = 0, l = terminatorRules.length; i < l; i++) + if (terminatorRules[i](state, nextLine, endLine, true)) { + terminate = true; + break; + } + if (terminate) break; + if (isOrdered) { + posAfterMarker = skipOrderedListMarker(state, nextLine); + if (posAfterMarker < 0) break; + start = state.bMarks[nextLine] + state.tShift[nextLine]; + } else { + posAfterMarker = skipBulletListMarker(state, nextLine); + if (posAfterMarker < 0) break; + } + if (markerCharCode !== state.src.charCodeAt(posAfterMarker - 1)) break; + } + if (isOrdered) token = state.push("ordered_list_close", "ol", -1); + else token = state.push("bullet_list_close", "ul", -1); + token.markup = String.fromCharCode(markerCharCode); + listLines[1] = nextLine; + state.line = nextLine; + state.parentType = oldParentType; + if (tight) markTightParagraphs(state, listTokIdx); + return true; +} +function reference(state, startLine, _endLine, silent) { + let pos = state.bMarks[startLine] + state.tShift[startLine]; + let max = state.eMarks[startLine]; + let nextLine = startLine + 1; + if (state.sCount[startLine] - state.blkIndent >= 4) return false; + if (state.src.charCodeAt(pos) !== 91) return false; + function getNextLine(nextLine) { + const endLine = state.lineMax; + if (nextLine >= endLine || state.isEmpty(nextLine)) return null; + let isContinuation = false; + if (state.sCount[nextLine] - state.blkIndent > 3) isContinuation = true; + if (state.sCount[nextLine] < 0) isContinuation = true; + if (!isContinuation) { + const terminatorRules = state.md.block.ruler.getRules("reference"); + const oldParentType = state.parentType; + state.parentType = "reference"; + let terminate = false; + for (let i = 0, l = terminatorRules.length; i < l; i++) + if (terminatorRules[i](state, nextLine, endLine, true)) { + terminate = true; + break; + } + state.parentType = oldParentType; + if (terminate) return null; + } + const pos = state.bMarks[nextLine] + state.tShift[nextLine]; + const max = state.eMarks[nextLine]; + return state.src.slice(pos, max + 1); + } + let str = state.src.slice(pos, max + 1); + max = str.length; + let labelEnd = -1; + for (pos = 1; pos < max; pos++) { + const ch = str.charCodeAt(pos); + if (ch === 91) return false; + else if (ch === 93) { + labelEnd = pos; + break; + } else if (ch === 10) { + const lineContent = getNextLine(nextLine); + if (lineContent !== null) { + str += lineContent; + max = str.length; + nextLine++; + } + } else if (ch === 92) { + pos++; + if (pos < max && str.charCodeAt(pos) === 10) { + const lineContent = getNextLine(nextLine); + if (lineContent !== null) { + str += lineContent; + max = str.length; + nextLine++; + } + } + } + } + if (labelEnd < 0 || str.charCodeAt(labelEnd + 1) !== 58) return false; + for (pos = labelEnd + 2; pos < max; pos++) { + const ch = str.charCodeAt(pos); + if (ch === 10) { + const lineContent = getNextLine(nextLine); + if (lineContent !== null) { + str += lineContent; + max = str.length; + nextLine++; + } + } else if (isSpace(ch)) { + } else break; + } + const destRes = state.md.helpers.parseLinkDestination(str, pos, max); + if (!destRes.ok) return false; + const href = state.md.normalizeLink(destRes.str); + if (!state.md.validateLink(href)) return false; + pos = destRes.pos; + const destEndPos = pos; + const destEndLineNo = nextLine; + const start = pos; + for (; pos < max; pos++) { + const ch = str.charCodeAt(pos); + if (ch === 10) { + const lineContent = getNextLine(nextLine); + if (lineContent !== null) { + str += lineContent; + max = str.length; + nextLine++; + } + } else if (isSpace(ch)) { + } else break; + } + let titleRes = state.md.helpers.parseLinkTitle(str, pos, max); + while (titleRes.can_continue) { + const lineContent = getNextLine(nextLine); + if (lineContent === null) break; + str += lineContent; + pos = max; + max = str.length; + nextLine++; + titleRes = state.md.helpers.parseLinkTitle(str, pos, max, titleRes); + } + let title; + if (pos < max && start !== pos && titleRes.ok) { + title = titleRes.str; + pos = titleRes.pos; + } else { + title = ""; + pos = destEndPos; + nextLine = destEndLineNo; + } + while (pos < max) { + if (!isSpace(str.charCodeAt(pos))) break; + pos++; + } + if (pos < max && str.charCodeAt(pos) !== 10) { + if (title) { + title = ""; + pos = destEndPos; + nextLine = destEndLineNo; + while (pos < max) { + if (!isSpace(str.charCodeAt(pos))) break; + pos++; + } + } + } + if (pos < max && str.charCodeAt(pos) !== 10) return false; + const label = normalizeReference(str.slice(1, labelEnd)); + if (!label) return false; + /* istanbul ignore if */ + if (silent) return true; + if (typeof state.env.references === "undefined") state.env.references = {}; + if (typeof state.env.references[label] === "undefined") + state.env.references[label] = { + title, + href, + }; + state.line = nextLine; + return true; +} +var html_blocks_default = [ + "address", + "article", + "aside", + "base", + "basefont", + "blockquote", + "body", + "caption", + "center", + "col", + "colgroup", + "dd", + "details", + "dialog", + "dir", + "div", + "dl", + "dt", + "fieldset", + "figcaption", + "figure", + "footer", + "form", + "frame", + "frameset", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "head", + "header", + "hr", + "html", + "iframe", + "legend", + "li", + "link", + "main", + "menu", + "menuitem", + "nav", + "noframes", + "ol", + "optgroup", + "option", + "p", + "param", + "search", + "section", + "summary", + "table", + "tbody", + "td", + "tfoot", + "th", + "thead", + "title", + "tr", + "track", + "ul", +]; +const open_tag = + "<[A-Za-z][A-Za-z0-9\\-]*(?:\\s+[a-zA-Z_:][a-zA-Z0-9:._-]*(?:\\s*=\\s*(?:[^\"'=<>`\\x00-\\x20]+|'[^']*'|\"[^\"]*\"))?)*\\s*\\/?>"; +const HTML_TAG_RE = new RegExp( + "^(?:" + + open_tag + + "|<\\/[A-Za-z][A-Za-z0-9\\-]*\\s*>||<[?][\\s\\S]*?[?]>|]*>|)", +); +const HTML_OPEN_CLOSE_TAG_RE = new RegExp("^(?:" + open_tag + "|<\\/[A-Za-z][A-Za-z0-9\\-]*\\s*>)"); +const HTML_SEQUENCES = [ + [/^<(script|pre|style|textarea)(?=(\s|>|$))/i, /<\/(script|pre|style|textarea)>/i, true], + [/^/, true], + [/^<\?/, /\?>/, true], + [/^/, true], + [/^/, true], + [new RegExp("^|$))", "i"), /^$/, true], + [new RegExp(HTML_OPEN_CLOSE_TAG_RE.source + "\\s*$"), /^$/, false], +]; +function html_block(state, startLine, endLine, silent) { + let pos = state.bMarks[startLine] + state.tShift[startLine]; + let max = state.eMarks[startLine]; + if (state.sCount[startLine] - state.blkIndent >= 4) return false; + if (!state.md.options.html) return false; + if (state.src.charCodeAt(pos) !== 60) return false; + let lineText = state.src.slice(pos, max); + let i = 0; + for (; i < HTML_SEQUENCES.length; i++) if (HTML_SEQUENCES[i][0].test(lineText)) break; + if (i === HTML_SEQUENCES.length) return false; + if (silent) return HTML_SEQUENCES[i][2]; + let nextLine = startLine + 1; + if (!HTML_SEQUENCES[i][1].test(lineText)) + for (; nextLine < endLine; nextLine++) { + if (state.sCount[nextLine] < state.blkIndent) break; + pos = state.bMarks[nextLine] + state.tShift[nextLine]; + max = state.eMarks[nextLine]; + lineText = state.src.slice(pos, max); + if (HTML_SEQUENCES[i][1].test(lineText)) { + if (lineText.length !== 0) nextLine++; + break; + } + } + state.line = nextLine; + const token = state.push("html_block", "", 0); + token.map = [startLine, nextLine]; + token.content = state.getLines(startLine, nextLine, state.blkIndent, true); + return true; +} +function heading(state, startLine, endLine, silent) { + let pos = state.bMarks[startLine] + state.tShift[startLine]; + let max = state.eMarks[startLine]; + if (state.sCount[startLine] - state.blkIndent >= 4) return false; + let ch = state.src.charCodeAt(pos); + if (ch !== 35 || pos >= max) return false; + let level = 1; + ch = state.src.charCodeAt(++pos); + while (ch === 35 && pos < max && level <= 6) { + level++; + ch = state.src.charCodeAt(++pos); + } + if (level > 6 || (pos < max && !isSpace(ch))) return false; + if (silent) return true; + max = state.skipSpacesBack(max, pos); + const tmp = state.skipCharsBack(max, 35, pos); + if (tmp > pos && isSpace(state.src.charCodeAt(tmp - 1))) max = tmp; + state.line = startLine + 1; + const token_o = state.push("heading_open", "h" + String(level), 1); + token_o.markup = "########".slice(0, level); + token_o.map = [startLine, state.line]; + const token_i = state.push("inline", "", 0); + token_i.content = state.src.slice(pos, max).trim(); + token_i.map = [startLine, state.line]; + token_i.children = []; + const token_c = state.push("heading_close", "h" + String(level), -1); + token_c.markup = "########".slice(0, level); + return true; +} +function lheading(state, startLine, endLine) { + const terminatorRules = state.md.block.ruler.getRules("paragraph"); + if (state.sCount[startLine] - state.blkIndent >= 4) return false; + const oldParentType = state.parentType; + state.parentType = "paragraph"; + let level = 0; + let marker; + let nextLine = startLine + 1; + for (; nextLine < endLine && !state.isEmpty(nextLine); nextLine++) { + if (state.sCount[nextLine] - state.blkIndent > 3) continue; + if (state.sCount[nextLine] >= state.blkIndent) { + let pos = state.bMarks[nextLine] + state.tShift[nextLine]; + const max = state.eMarks[nextLine]; + if (pos < max) { + marker = state.src.charCodeAt(pos); + if (marker === 45 || marker === 61) { + pos = state.skipChars(pos, marker); + pos = state.skipSpaces(pos); + if (pos >= max) { + level = marker === 61 ? 1 : 2; + break; + } + } + } + } + if (state.sCount[nextLine] < 0) continue; + let terminate = false; + for (let i = 0, l = terminatorRules.length; i < l; i++) + if (terminatorRules[i](state, nextLine, endLine, true)) { + terminate = true; + break; + } + if (terminate) break; + } + if (!level) return false; + const content = state.getLines(startLine, nextLine, state.blkIndent, false).trim(); + state.line = nextLine + 1; + const token_o = state.push("heading_open", "h" + String(level), 1); + token_o.markup = String.fromCharCode(marker); + token_o.map = [startLine, state.line]; + const token_i = state.push("inline", "", 0); + token_i.content = content; + token_i.map = [startLine, state.line - 1]; + token_i.children = []; + const token_c = state.push("heading_close", "h" + String(level), -1); + token_c.markup = String.fromCharCode(marker); + state.parentType = oldParentType; + return true; +} +function paragraph(state, startLine, endLine) { + const terminatorRules = state.md.block.ruler.getRules("paragraph"); + const oldParentType = state.parentType; + let nextLine = startLine + 1; + state.parentType = "paragraph"; + for (; nextLine < endLine && !state.isEmpty(nextLine); nextLine++) { + if (state.sCount[nextLine] - state.blkIndent > 3) continue; + if (state.sCount[nextLine] < 0) continue; + let terminate = false; + for (let i = 0, l = terminatorRules.length; i < l; i++) + if (terminatorRules[i](state, nextLine, endLine, true)) { + terminate = true; + break; + } + if (terminate) break; + } + const content = state.getLines(startLine, nextLine, state.blkIndent, false).trim(); + state.line = nextLine; + const token_o = state.push("paragraph_open", "p", 1); + token_o.map = [startLine, state.line]; + const token_i = state.push("inline", "", 0); + token_i.content = content; + token_i.map = [startLine, state.line]; + token_i.children = []; + state.push("paragraph_close", "p", -1); + state.parentType = oldParentType; + return true; +} +/** internal + * class ParserBlock + * + * Block-level tokenizer. + **/ +const _rules$1 = [ + ["table", table, ["paragraph", "reference"]], + ["code", code], + ["fence", fence, ["paragraph", "reference", "blockquote", "list"]], + ["blockquote", blockquote, ["paragraph", "reference", "blockquote", "list"]], + ["hr", hr, ["paragraph", "reference", "blockquote", "list"]], + ["list", list, ["paragraph", "reference", "blockquote"]], + ["reference", reference], + ["html_block", html_block, ["paragraph", "reference", "blockquote"]], + ["heading", heading, ["paragraph", "reference", "blockquote"]], + ["lheading", lheading], + ["paragraph", paragraph], +]; +/** + * new ParserBlock() + **/ +function ParserBlock() { + /** + * ParserBlock#ruler -> Ruler + * + * [[Ruler]] instance. Keep configuration of block rules. + **/ + this.ruler = new Ruler(); + for (let i = 0; i < _rules$1.length; i++) + this.ruler.push(_rules$1[i][0], _rules$1[i][1], { alt: (_rules$1[i][2] || []).slice() }); +} +ParserBlock.prototype.tokenize = function (state, startLine, endLine) { + const rules = this.ruler.getRules(""); + const len = rules.length; + const maxNesting = state.md.options.maxNesting; + let line = startLine; + let hasEmptyLines = false; + while (line < endLine) { + state.line = line = state.skipEmptyLines(line); + if (line >= endLine) break; + if (state.sCount[line] < state.blkIndent) break; + if (state.level >= maxNesting) { + state.line = endLine; + break; + } + const prevLine = state.line; + let ok = false; + for (let i = 0; i < len; i++) { + ok = rules[i](state, line, endLine, false); + if (ok) { + if (prevLine >= state.line) throw new Error("block rule didn't increment state.line"); + break; + } + } + if (!ok) throw new Error("none of the block rules matched"); + state.tight = !hasEmptyLines; + if (state.isEmpty(state.line - 1)) hasEmptyLines = true; + line = state.line; + if (line < endLine && state.isEmpty(line)) { + hasEmptyLines = true; + line++; + state.line = line; + } + } +}; +/** + * ParserBlock.parse(str, md, env, outTokens) + * + * Process input string and push block tokens into `outTokens` + **/ +ParserBlock.prototype.parse = function (src, md, env, outTokens) { + if (!src) return; + const state = new this.State(src, md, env, outTokens); + this.tokenize(state, state.line, state.lineMax); +}; +ParserBlock.prototype.State = StateBlock; +function StateInline(src, md, env, outTokens) { + this.src = src; + this.env = env; + this.md = md; + this.tokens = outTokens; + this.tokens_meta = Array(outTokens.length); + this.pos = 0; + this.posMax = this.src.length; + this.level = 0; + this.pending = ""; + this.pendingLevel = 0; + this.cache = {}; + this.delimiters = []; + this._prev_delimiters = []; + this.backticks = {}; + this.backticksScanned = false; + this.linkLevel = 0; +} +StateInline.prototype.pushPending = function () { + const token = new Token("text", "", 0); + token.content = this.pending; + token.level = this.pendingLevel; + this.tokens.push(token); + this.pending = ""; + return token; +}; +StateInline.prototype.push = function (type, tag, nesting) { + if (this.pending) this.pushPending(); + const token = new Token(type, tag, nesting); + let token_meta = null; + if (nesting < 0) { + this.level--; + this.delimiters = this._prev_delimiters.pop(); + } + token.level = this.level; + if (nesting > 0) { + this.level++; + this._prev_delimiters.push(this.delimiters); + this.delimiters = []; + token_meta = { delimiters: this.delimiters }; + } + this.pendingLevel = this.level; + this.tokens.push(token); + this.tokens_meta.push(token_meta); + return token; +}; +StateInline.prototype.scanDelims = function (start, canSplitWord) { + const max = this.posMax; + const marker = this.src.charCodeAt(start); + const lastChar = start > 0 ? this.src.charCodeAt(start - 1) : 32; + let pos = start; + while (pos < max && this.src.charCodeAt(pos) === marker) pos++; + const count = pos - start; + const nextChar = pos < max ? this.src.charCodeAt(pos) : 32; + const isLastPunctChar = isMdAsciiPunct(lastChar) || isPunctChar(String.fromCharCode(lastChar)); + const isNextPunctChar = isMdAsciiPunct(nextChar) || isPunctChar(String.fromCharCode(nextChar)); + const isLastWhiteSpace = isWhiteSpace(lastChar); + const isNextWhiteSpace = isWhiteSpace(nextChar); + const left_flanking = + !isNextWhiteSpace && (!isNextPunctChar || isLastWhiteSpace || isLastPunctChar); + const right_flanking = + !isLastWhiteSpace && (!isLastPunctChar || isNextWhiteSpace || isNextPunctChar); + return { + can_open: left_flanking && (canSplitWord || !right_flanking || isLastPunctChar), + can_close: right_flanking && (canSplitWord || !left_flanking || isNextPunctChar), + length: count, + }; +}; +StateInline.prototype.Token = Token; +function isTerminatorChar(ch) { + switch (ch) { + case 10: + case 33: + case 35: + case 36: + case 37: + case 38: + case 42: + case 43: + case 45: + case 58: + case 60: + case 61: + case 62: + case 64: + case 91: + case 92: + case 93: + case 94: + case 95: + case 96: + case 123: + case 125: + case 126: + return true; + default: + return false; + } +} +function text(state, silent) { + let pos = state.pos; + while (pos < state.posMax && !isTerminatorChar(state.src.charCodeAt(pos))) pos++; + if (pos === state.pos) return false; + if (!silent) state.pending += state.src.slice(state.pos, pos); + state.pos = pos; + return true; +} +const SCHEME_RE = /(?:^|[^a-z0-9.+-])([a-z][a-z0-9.+-]*)$/i; +function linkify(state, silent) { + if (!state.md.options.linkify) return false; + if (state.linkLevel > 0) return false; + const pos = state.pos; + const max = state.posMax; + if (pos + 3 > max) return false; + if (state.src.charCodeAt(pos) !== 58) return false; + if (state.src.charCodeAt(pos + 1) !== 47) return false; + if (state.src.charCodeAt(pos + 2) !== 47) return false; + const match = state.pending.match(SCHEME_RE); + if (!match) return false; + const proto = match[1]; + const link = state.md.linkify.matchAtStart(state.src.slice(pos - proto.length)); + if (!link) return false; + let url = link.url; + if (url.length <= proto.length) return false; + let urlEnd = url.length; + while (urlEnd > 0 && url.charCodeAt(urlEnd - 1) === 42) urlEnd--; + if (urlEnd !== url.length) url = url.slice(0, urlEnd); + const fullUrl = state.md.normalizeLink(url); + if (!state.md.validateLink(fullUrl)) return false; + if (!silent) { + state.pending = state.pending.slice(0, -proto.length); + const token_o = state.push("link_open", "a", 1); + token_o.attrs = [["href", fullUrl]]; + token_o.markup = "linkify"; + token_o.info = "auto"; + const token_t = state.push("text", "", 0); + token_t.content = state.md.normalizeLinkText(url); + const token_c = state.push("link_close", "a", -1); + token_c.markup = "linkify"; + token_c.info = "auto"; + } + state.pos += url.length - proto.length; + return true; +} +function newline(state, silent) { + let pos = state.pos; + if (state.src.charCodeAt(pos) !== 10) return false; + const pmax = state.pending.length - 1; + const max = state.posMax; + if (!silent) + if (pmax >= 0 && state.pending.charCodeAt(pmax) === 32) + if (pmax >= 1 && state.pending.charCodeAt(pmax - 1) === 32) { + let ws = pmax - 1; + while (ws >= 1 && state.pending.charCodeAt(ws - 1) === 32) ws--; + state.pending = state.pending.slice(0, ws); + state.push("hardbreak", "br", 0); + } else { + state.pending = state.pending.slice(0, -1); + state.push("softbreak", "br", 0); + } + else state.push("softbreak", "br", 0); + pos++; + while (pos < max && isSpace(state.src.charCodeAt(pos))) pos++; + state.pos = pos; + return true; +} +const ESCAPED = []; +for (let i = 0; i < 256; i++) ESCAPED.push(0); +"\\!\"#$%&'()*+,./:;<=>?@[]^_`{|}~-".split("").forEach(function (ch) { + ESCAPED[ch.charCodeAt(0)] = 1; +}); +function escape(state, silent) { + let pos = state.pos; + const max = state.posMax; + if (state.src.charCodeAt(pos) !== 92) return false; + pos++; + if (pos >= max) return false; + let ch1 = state.src.charCodeAt(pos); + if (ch1 === 10) { + if (!silent) state.push("hardbreak", "br", 0); + pos++; + while (pos < max) { + ch1 = state.src.charCodeAt(pos); + if (!isSpace(ch1)) break; + pos++; + } + state.pos = pos; + return true; + } + let escapedStr = state.src[pos]; + if (ch1 >= 55296 && ch1 <= 56319 && pos + 1 < max) { + const ch2 = state.src.charCodeAt(pos + 1); + if (ch2 >= 56320 && ch2 <= 57343) { + escapedStr += state.src[pos + 1]; + pos++; + } + } + const origStr = "\\" + escapedStr; + if (!silent) { + const token = state.push("text_special", "", 0); + if (ch1 < 256 && ESCAPED[ch1] !== 0) token.content = escapedStr; + else token.content = origStr; + token.markup = origStr; + token.info = "escape"; + } + state.pos = pos + 1; + return true; +} +function backtick(state, silent) { + let pos = state.pos; + if (state.src.charCodeAt(pos) !== 96) return false; + const start = pos; + pos++; + const max = state.posMax; + while (pos < max && state.src.charCodeAt(pos) === 96) pos++; + const marker = state.src.slice(start, pos); + const openerLength = marker.length; + if (state.backticksScanned && (state.backticks[openerLength] || 0) <= start) { + if (!silent) state.pending += marker; + state.pos += openerLength; + return true; + } + let matchEnd = pos; + let matchStart; + while ((matchStart = state.src.indexOf("`", matchEnd)) !== -1) { + matchEnd = matchStart + 1; + while (matchEnd < max && state.src.charCodeAt(matchEnd) === 96) matchEnd++; + const closerLength = matchEnd - matchStart; + if (closerLength === openerLength) { + if (!silent) { + const token = state.push("code_inline", "code", 0); + token.markup = marker; + token.content = state.src + .slice(pos, matchStart) + .replace(/\n/g, " ") + .replace(/^ (.+) $/, "$1"); + } + state.pos = matchEnd; + return true; + } + state.backticks[closerLength] = matchStart; + } + state.backticksScanned = true; + if (!silent) state.pending += marker; + state.pos += openerLength; + return true; +} +function strikethrough_tokenize(state, silent) { + const start = state.pos; + const marker = state.src.charCodeAt(start); + if (silent) return false; + if (marker !== 126) return false; + const scanned = state.scanDelims(state.pos, true); + let len = scanned.length; + const ch = String.fromCharCode(marker); + if (len < 2) return false; + let token; + if (len % 2) { + token = state.push("text", "", 0); + token.content = ch; + len--; + } + for (let i = 0; i < len; i += 2) { + token = state.push("text", "", 0); + token.content = ch + ch; + state.delimiters.push({ + marker, + length: 0, + token: state.tokens.length - 1, + end: -1, + open: scanned.can_open, + close: scanned.can_close, + }); + } + state.pos += scanned.length; + return true; +} +function postProcess$1(state, delimiters) { + let token; + const loneMarkers = []; + const max = delimiters.length; + for (let i = 0; i < max; i++) { + const startDelim = delimiters[i]; + if (startDelim.marker !== 126) continue; + if (startDelim.end === -1) continue; + const endDelim = delimiters[startDelim.end]; + token = state.tokens[startDelim.token]; + token.type = "s_open"; + token.tag = "s"; + token.nesting = 1; + token.markup = "~~"; + token.content = ""; + token = state.tokens[endDelim.token]; + token.type = "s_close"; + token.tag = "s"; + token.nesting = -1; + token.markup = "~~"; + token.content = ""; + if ( + state.tokens[endDelim.token - 1].type === "text" && + state.tokens[endDelim.token - 1].content === "~" + ) + loneMarkers.push(endDelim.token - 1); + } + while (loneMarkers.length) { + const i = loneMarkers.pop(); + let j = i + 1; + while (j < state.tokens.length && state.tokens[j].type === "s_close") j++; + j--; + if (i !== j) { + token = state.tokens[j]; + state.tokens[j] = state.tokens[i]; + state.tokens[i] = token; + } + } +} +function strikethrough_postProcess(state) { + const tokens_meta = state.tokens_meta; + const max = state.tokens_meta.length; + postProcess$1(state, state.delimiters); + for (let curr = 0; curr < max; curr++) + if (tokens_meta[curr] && tokens_meta[curr].delimiters) + postProcess$1(state, tokens_meta[curr].delimiters); +} +var strikethrough_default = { + tokenize: strikethrough_tokenize, + postProcess: strikethrough_postProcess, +}; +function emphasis_tokenize(state, silent) { + const start = state.pos; + const marker = state.src.charCodeAt(start); + if (silent) return false; + if (marker !== 95 && marker !== 42) return false; + const scanned = state.scanDelims(state.pos, marker === 42); + for (let i = 0; i < scanned.length; i++) { + const token = state.push("text", "", 0); + token.content = String.fromCharCode(marker); + state.delimiters.push({ + marker, + length: scanned.length, + token: state.tokens.length - 1, + end: -1, + open: scanned.can_open, + close: scanned.can_close, + }); + } + state.pos += scanned.length; + return true; +} +function postProcess(state, delimiters) { + const max = delimiters.length; + for (let i = max - 1; i >= 0; i--) { + const startDelim = delimiters[i]; + if (startDelim.marker !== 95 && startDelim.marker !== 42) continue; + if (startDelim.end === -1) continue; + const endDelim = delimiters[startDelim.end]; + const isStrong = + i > 0 && + delimiters[i - 1].end === startDelim.end + 1 && + delimiters[i - 1].marker === startDelim.marker && + delimiters[i - 1].token === startDelim.token - 1 && + delimiters[startDelim.end + 1].token === endDelim.token + 1; + const ch = String.fromCharCode(startDelim.marker); + const token_o = state.tokens[startDelim.token]; + token_o.type = isStrong ? "strong_open" : "em_open"; + token_o.tag = isStrong ? "strong" : "em"; + token_o.nesting = 1; + token_o.markup = isStrong ? ch + ch : ch; + token_o.content = ""; + const token_c = state.tokens[endDelim.token]; + token_c.type = isStrong ? "strong_close" : "em_close"; + token_c.tag = isStrong ? "strong" : "em"; + token_c.nesting = -1; + token_c.markup = isStrong ? ch + ch : ch; + token_c.content = ""; + if (isStrong) { + state.tokens[delimiters[i - 1].token].content = ""; + state.tokens[delimiters[startDelim.end + 1].token].content = ""; + i--; + } + } +} +function emphasis_post_process(state) { + const tokens_meta = state.tokens_meta; + const max = state.tokens_meta.length; + postProcess(state, state.delimiters); + for (let curr = 0; curr < max; curr++) + if (tokens_meta[curr] && tokens_meta[curr].delimiters) + postProcess(state, tokens_meta[curr].delimiters); +} +var emphasis_default = { + tokenize: emphasis_tokenize, + postProcess: emphasis_post_process, +}; +function link(state, silent) { + let code, label, res, ref; + let href = ""; + let title = ""; + let start = state.pos; + let parseReference = true; + if (state.src.charCodeAt(state.pos) !== 91) return false; + const oldPos = state.pos; + const max = state.posMax; + const labelStart = state.pos + 1; + const labelEnd = state.md.helpers.parseLinkLabel(state, state.pos, true); + if (labelEnd < 0) return false; + let pos = labelEnd + 1; + if (pos < max && state.src.charCodeAt(pos) === 40) { + parseReference = false; + pos++; + for (; pos < max; pos++) { + code = state.src.charCodeAt(pos); + if (!isSpace(code) && code !== 10) break; + } + if (pos >= max) return false; + start = pos; + res = state.md.helpers.parseLinkDestination(state.src, pos, state.posMax); + if (res.ok) { + href = state.md.normalizeLink(res.str); + if (state.md.validateLink(href)) pos = res.pos; + else href = ""; + start = pos; + for (; pos < max; pos++) { + code = state.src.charCodeAt(pos); + if (!isSpace(code) && code !== 10) break; + } + res = state.md.helpers.parseLinkTitle(state.src, pos, state.posMax); + if (pos < max && start !== pos && res.ok) { + title = res.str; + pos = res.pos; + for (; pos < max; pos++) { + code = state.src.charCodeAt(pos); + if (!isSpace(code) && code !== 10) break; + } + } + } + if (pos >= max || state.src.charCodeAt(pos) !== 41) parseReference = true; + pos++; + } + if (parseReference) { + if (typeof state.env.references === "undefined") return false; + if (pos < max && state.src.charCodeAt(pos) === 91) { + start = pos + 1; + pos = state.md.helpers.parseLinkLabel(state, pos); + if (pos >= 0) label = state.src.slice(start, pos++); + else pos = labelEnd + 1; + } else pos = labelEnd + 1; + if (!label) label = state.src.slice(labelStart, labelEnd); + ref = state.env.references[normalizeReference(label)]; + if (!ref) { + state.pos = oldPos; + return false; + } + href = ref.href; + title = ref.title; + } + if (!silent) { + state.pos = labelStart; + state.posMax = labelEnd; + const token_o = state.push("link_open", "a", 1); + const attrs = [["href", href]]; + token_o.attrs = attrs; + if (title) attrs.push(["title", title]); + state.linkLevel++; + state.md.inline.tokenize(state); + state.linkLevel--; + state.push("link_close", "a", -1); + } + state.pos = pos; + state.posMax = max; + return true; +} +function image(state, silent) { + let code, content, label, pos, ref, res, title, start; + let href = ""; + const oldPos = state.pos; + const max = state.posMax; + if (state.src.charCodeAt(state.pos) !== 33) return false; + if (state.src.charCodeAt(state.pos + 1) !== 91) return false; + const labelStart = state.pos + 2; + const labelEnd = state.md.helpers.parseLinkLabel(state, state.pos + 1, false); + if (labelEnd < 0) return false; + pos = labelEnd + 1; + if (pos < max && state.src.charCodeAt(pos) === 40) { + pos++; + for (; pos < max; pos++) { + code = state.src.charCodeAt(pos); + if (!isSpace(code) && code !== 10) break; + } + if (pos >= max) return false; + start = pos; + res = state.md.helpers.parseLinkDestination(state.src, pos, state.posMax); + if (res.ok) { + href = state.md.normalizeLink(res.str); + if (state.md.validateLink(href)) pos = res.pos; + else href = ""; + } + start = pos; + for (; pos < max; pos++) { + code = state.src.charCodeAt(pos); + if (!isSpace(code) && code !== 10) break; + } + res = state.md.helpers.parseLinkTitle(state.src, pos, state.posMax); + if (pos < max && start !== pos && res.ok) { + title = res.str; + pos = res.pos; + for (; pos < max; pos++) { + code = state.src.charCodeAt(pos); + if (!isSpace(code) && code !== 10) break; + } + } else title = ""; + if (pos >= max || state.src.charCodeAt(pos) !== 41) { + state.pos = oldPos; + return false; + } + pos++; + } else { + if (typeof state.env.references === "undefined") return false; + if (pos < max && state.src.charCodeAt(pos) === 91) { + start = pos + 1; + pos = state.md.helpers.parseLinkLabel(state, pos); + if (pos >= 0) label = state.src.slice(start, pos++); + else pos = labelEnd + 1; + } else pos = labelEnd + 1; + if (!label) label = state.src.slice(labelStart, labelEnd); + ref = state.env.references[normalizeReference(label)]; + if (!ref) { + state.pos = oldPos; + return false; + } + href = ref.href; + title = ref.title; + } + if (!silent) { + content = state.src.slice(labelStart, labelEnd); + const tokens = []; + state.md.inline.parse(content, state.md, state.env, tokens); + const token = state.push("image", "img", 0); + const attrs = [ + ["src", href], + ["alt", ""], + ]; + token.attrs = attrs; + token.children = tokens; + token.content = content; + if (title) attrs.push(["title", title]); + } + state.pos = pos; + state.posMax = max; + return true; +} +const EMAIL_RE = + /^([a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)$/; +const AUTOLINK_RE = /^([a-zA-Z][a-zA-Z0-9+.-]{1,31}):([^<>\x00-\x20]*)$/; +function autolink(state, silent) { + let pos = state.pos; + if (state.src.charCodeAt(pos) !== 60) return false; + const start = state.pos; + const max = state.posMax; + for (;;) { + if (++pos >= max) return false; + const ch = state.src.charCodeAt(pos); + if (ch === 60) return false; + if (ch === 62) break; + } + const url = state.src.slice(start + 1, pos); + if (AUTOLINK_RE.test(url)) { + const fullUrl = state.md.normalizeLink(url); + if (!state.md.validateLink(fullUrl)) return false; + if (!silent) { + const token_o = state.push("link_open", "a", 1); + token_o.attrs = [["href", fullUrl]]; + token_o.markup = "autolink"; + token_o.info = "auto"; + const token_t = state.push("text", "", 0); + token_t.content = state.md.normalizeLinkText(url); + const token_c = state.push("link_close", "a", -1); + token_c.markup = "autolink"; + token_c.info = "auto"; + } + state.pos += url.length + 2; + return true; + } + if (EMAIL_RE.test(url)) { + const fullUrl = state.md.normalizeLink("mailto:" + url); + if (!state.md.validateLink(fullUrl)) return false; + if (!silent) { + const token_o = state.push("link_open", "a", 1); + token_o.attrs = [["href", fullUrl]]; + token_o.markup = "autolink"; + token_o.info = "auto"; + const token_t = state.push("text", "", 0); + token_t.content = state.md.normalizeLinkText(url); + const token_c = state.push("link_close", "a", -1); + token_c.markup = "autolink"; + token_c.info = "auto"; + } + state.pos += url.length + 2; + return true; + } + return false; +} +function isLinkOpen(str) { + return /^\s]/i.test(str); +} +function isLinkClose(str) { + return /^<\/a\s*>/i.test(str); +} +function isLetter(ch) { + const lc = ch | 32; + return lc >= 97 && lc <= 122; +} +function html_inline(state, silent) { + if (!state.md.options.html) return false; + const max = state.posMax; + const pos = state.pos; + if (state.src.charCodeAt(pos) !== 60 || pos + 2 >= max) return false; + const ch = state.src.charCodeAt(pos + 1); + if (ch !== 33 && ch !== 63 && ch !== 47 && !isLetter(ch)) return false; + const match = state.src.slice(pos).match(HTML_TAG_RE); + if (!match) return false; + if (!silent) { + const token = state.push("html_inline", "", 0); + token.content = match[0]; + if (isLinkOpen(token.content)) state.linkLevel++; + if (isLinkClose(token.content)) state.linkLevel--; + } + state.pos += match[0].length; + return true; +} +const DIGITAL_RE = /^&#((?:x[a-f0-9]{1,6}|[0-9]{1,7}));/i; +const NAMED_RE = /^&([a-z][a-z0-9]{1,31});/i; +function entity(state, silent) { + const pos = state.pos; + const max = state.posMax; + if (state.src.charCodeAt(pos) !== 38) return false; + if (pos + 1 >= max) return false; + if (state.src.charCodeAt(pos + 1) === 35) { + const match = state.src.slice(pos).match(DIGITAL_RE); + if (match) { + if (!silent) { + const code = + match[1][0].toLowerCase() === "x" + ? parseInt(match[1].slice(1), 16) + : parseInt(match[1], 10); + const token = state.push("text_special", "", 0); + token.content = isValidEntityCode(code) ? fromCodePoint(code) : fromCodePoint(65533); + token.markup = match[0]; + token.info = "entity"; + } + state.pos += match[0].length; + return true; + } + } else { + const match = state.src.slice(pos).match(NAMED_RE); + if (match) { + const decoded = decodeHTML(match[0]); + if (decoded !== match[0]) { + if (!silent) { + const token = state.push("text_special", "", 0); + token.content = decoded; + token.markup = match[0]; + token.info = "entity"; + } + state.pos += match[0].length; + return true; + } + } + } + return false; +} +function processDelimiters(delimiters) { + const openersBottom = {}; + const max = delimiters.length; + if (!max) return; + let headerIdx = 0; + let lastTokenIdx = -2; + const jumps = []; + for (let closerIdx = 0; closerIdx < max; closerIdx++) { + const closer = delimiters[closerIdx]; + jumps.push(0); + if (delimiters[headerIdx].marker !== closer.marker || lastTokenIdx !== closer.token - 1) + headerIdx = closerIdx; + lastTokenIdx = closer.token; + closer.length = closer.length || 0; + if (!closer.close) continue; + if (!openersBottom.hasOwnProperty(closer.marker)) + openersBottom[closer.marker] = [-1, -1, -1, -1, -1, -1]; + const minOpenerIdx = openersBottom[closer.marker][(closer.open ? 3 : 0) + (closer.length % 3)]; + let openerIdx = headerIdx - jumps[headerIdx] - 1; + let newMinOpenerIdx = openerIdx; + for (; openerIdx > minOpenerIdx; openerIdx -= jumps[openerIdx] + 1) { + const opener = delimiters[openerIdx]; + if (opener.marker !== closer.marker) continue; + if (opener.open && opener.end < 0) { + let isOddMatch = false; + if (opener.close || closer.open) { + if ((opener.length + closer.length) % 3 === 0) { + if (opener.length % 3 !== 0 || closer.length % 3 !== 0) isOddMatch = true; + } + } + if (!isOddMatch) { + const lastJump = + openerIdx > 0 && !delimiters[openerIdx - 1].open ? jumps[openerIdx - 1] + 1 : 0; + jumps[closerIdx] = closerIdx - openerIdx + lastJump; + jumps[openerIdx] = lastJump; + closer.open = false; + opener.end = closerIdx; + opener.close = false; + newMinOpenerIdx = -1; + lastTokenIdx = -2; + break; + } + } + } + if (newMinOpenerIdx !== -1) + openersBottom[closer.marker][(closer.open ? 3 : 0) + ((closer.length || 0) % 3)] = + newMinOpenerIdx; + } +} +function link_pairs(state) { + const tokens_meta = state.tokens_meta; + const max = state.tokens_meta.length; + processDelimiters(state.delimiters); + for (let curr = 0; curr < max; curr++) + if (tokens_meta[curr] && tokens_meta[curr].delimiters) + processDelimiters(tokens_meta[curr].delimiters); +} +function fragments_join(state) { + let curr, last; + let level = 0; + const tokens = state.tokens; + const max = state.tokens.length; + for (curr = last = 0; curr < max; curr++) { + if (tokens[curr].nesting < 0) level--; + tokens[curr].level = level; + if (tokens[curr].nesting > 0) level++; + if (tokens[curr].type === "text" && curr + 1 < max && tokens[curr + 1].type === "text") + tokens[curr + 1].content = tokens[curr].content + tokens[curr + 1].content; + else { + if (curr !== last) tokens[last] = tokens[curr]; + last++; + } + } + if (curr !== last) tokens.length = last; +} +/** internal + * class ParserInline + * + * Tokenizes paragraph content. + **/ +const _rules = [ + ["text", text], + ["linkify", linkify], + ["newline", newline], + ["escape", escape], + ["backticks", backtick], + ["strikethrough", strikethrough_default.tokenize], + ["emphasis", emphasis_default.tokenize], + ["link", link], + ["image", image], + ["autolink", autolink], + ["html_inline", html_inline], + ["entity", entity], +]; +const _rules2 = [ + ["balance_pairs", link_pairs], + ["strikethrough", strikethrough_default.postProcess], + ["emphasis", emphasis_default.postProcess], + ["fragments_join", fragments_join], +]; +/** + * new ParserInline() + **/ +function ParserInline() { + /** + * ParserInline#ruler -> Ruler + * + * [[Ruler]] instance. Keep configuration of inline rules. + **/ + this.ruler = new Ruler(); + for (let i = 0; i < _rules.length; i++) this.ruler.push(_rules[i][0], _rules[i][1]); + /** + * ParserInline#ruler2 -> Ruler + * + * [[Ruler]] instance. Second ruler used for post-processing + * (e.g. in emphasis-like rules). + **/ + this.ruler2 = new Ruler(); + for (let i = 0; i < _rules2.length; i++) this.ruler2.push(_rules2[i][0], _rules2[i][1]); +} +ParserInline.prototype.skipToken = function (state) { + const pos = state.pos; + const rules = this.ruler.getRules(""); + const len = rules.length; + const maxNesting = state.md.options.maxNesting; + const cache = state.cache; + if (typeof cache[pos] !== "undefined") { + state.pos = cache[pos]; + return; + } + let ok = false; + if (state.level < maxNesting) + for (let i = 0; i < len; i++) { + state.level++; + ok = rules[i](state, true); + state.level--; + if (ok) { + if (pos >= state.pos) throw new Error("inline rule didn't increment state.pos"); + break; + } + } + else state.pos = state.posMax; + if (!ok) state.pos++; + cache[pos] = state.pos; +}; +ParserInline.prototype.tokenize = function (state) { + const rules = this.ruler.getRules(""); + const len = rules.length; + const end = state.posMax; + const maxNesting = state.md.options.maxNesting; + while (state.pos < end) { + const prevPos = state.pos; + let ok = false; + if (state.level < maxNesting) + for (let i = 0; i < len; i++) { + ok = rules[i](state, false); + if (ok) { + if (prevPos >= state.pos) throw new Error("inline rule didn't increment state.pos"); + break; + } + } + if (ok) { + if (state.pos >= end) break; + continue; + } + state.pending += state.src[state.pos++]; + } + if (state.pending) state.pushPending(); +}; +/** + * ParserInline.parse(str, md, env, outTokens) + * + * Process input string and push inline tokens into `outTokens` + **/ +ParserInline.prototype.parse = function (str, md, env, outTokens) { + const state = new this.State(str, md, env, outTokens); + this.tokenize(state); + const rules = this.ruler2.getRules(""); + const len = rules.length; + for (let i = 0; i < len; i++) rules[i](state); +}; +ParserInline.prototype.State = StateInline; +function re_default(opts) { + const re = {}; + opts = opts || {}; + re.src_Any = regex_default$5.source; + re.src_Cc = regex_default$4.source; + re.src_Z = regex_default.source; + re.src_P = regex_default$2.source; + re.src_ZPCc = [re.src_Z, re.src_P, re.src_Cc].join("|"); + re.src_ZCc = [re.src_Z, re.src_Cc].join("|"); + const text_separators = "[><|]"; + re.src_pseudo_letter = "(?:(?!" + text_separators + "|" + re.src_ZPCc + ")" + re.src_Any + ")"; + re.src_ip4 = + "(?:(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)"; + re.src_auth = "(?:(?:(?!" + re.src_ZCc + "|[@/\\[\\]()]).)+@)?"; + re.src_port = "(?::(?:6(?:[0-4]\\d{3}|5(?:[0-4]\\d{2}|5(?:[0-2]\\d|3[0-5])))|[1-5]?\\d{1,4}))?"; + re.src_host_terminator = + "(?=$|" + + text_separators + + "|" + + re.src_ZPCc + + ")(?!" + + (opts["---"] ? "-(?!--)|" : "-|") + + "_|:\\d|\\.-|\\.(?!$|" + + re.src_ZPCc + + "))"; + re.src_path = + "(?:[/?#](?:(?!" + + re.src_ZCc + + "|[><|]|[()[\\]{}.,\"'?!\\-;]).|\\[(?:(?!" + + re.src_ZCc + + "|\\]).)*\\]|\\((?:(?!" + + re.src_ZCc + + "|[)]).)*\\)|\\{(?:(?!" + + re.src_ZCc + + '|[}]).)*\\}|\\"(?:(?!' + + re.src_ZCc + + '|["]).)+\\"|\\\'(?:(?!' + + re.src_ZCc + + "|[']).)+\\'|\\'(?=" + + re.src_pseudo_letter + + "|[-])|\\.{2,}[a-zA-Z0-9%/&]|\\.(?!" + + re.src_ZCc + + "|[.]|$)|" + + (opts["---"] ? "\\-(?!--(?:[^-]|$))(?:-*)|" : "\\-+|") + + ",(?!" + + re.src_ZCc + + "|$)|;(?!" + + re.src_ZCc + + "|$)|\\!+(?!" + + re.src_ZCc + + "|[!]|$)|\\?(?!" + + re.src_ZCc + + "|[?]|$))+|\\/)?"; + re.src_email_name = '[\\-;:&=\\+\\$,\\.a-zA-Z0-9_][\\-;:&=\\+\\$,\\"\\.a-zA-Z0-9_]*'; + re.src_xn = "xn--[a-z0-9\\-]{1,59}"; + re.src_domain_root = "(?:" + re.src_xn + "|" + re.src_pseudo_letter + "{1,63})"; + re.src_domain = + "(?:" + + re.src_xn + + "|(?:" + + re.src_pseudo_letter + + ")|(?:" + + re.src_pseudo_letter + + "(?:-|" + + re.src_pseudo_letter + + "){0,61}" + + re.src_pseudo_letter + + "))"; + re.src_host = "(?:(?:(?:(?:" + re.src_domain + ")\\.)*" + re.src_domain + "))"; + re.tpl_host_fuzzy = "(?:" + re.src_ip4 + "|(?:(?:(?:" + re.src_domain + ")\\.)+(?:%TLDS%)))"; + re.tpl_host_no_ip_fuzzy = "(?:(?:(?:" + re.src_domain + ")\\.)+(?:%TLDS%))"; + re.src_host_strict = re.src_host + re.src_host_terminator; + re.tpl_host_fuzzy_strict = re.tpl_host_fuzzy + re.src_host_terminator; + re.src_host_port_strict = re.src_host + re.src_port + re.src_host_terminator; + re.tpl_host_port_fuzzy_strict = re.tpl_host_fuzzy + re.src_port + re.src_host_terminator; + re.tpl_host_port_no_ip_fuzzy_strict = + re.tpl_host_no_ip_fuzzy + re.src_port + re.src_host_terminator; + re.tpl_host_fuzzy_test = + "localhost|www\\.|\\.\\d{1,3}\\.|(?:\\.(?:%TLDS%)(?:" + re.src_ZPCc + "|>|$))"; + re.tpl_email_fuzzy = + "(^|" + + text_separators + + '|"|\\(|' + + re.src_ZCc + + ")(" + + re.src_email_name + + "@" + + re.tpl_host_fuzzy_strict + + ")"; + re.tpl_link_fuzzy = + "(^|(?![.:/\\-_@])(?:[$+<=>^`||]|" + + re.src_ZPCc + + "))((?![$+<=>^`||])" + + re.tpl_host_port_fuzzy_strict + + re.src_path + + ")"; + re.tpl_link_no_ip_fuzzy = + "(^|(?![.:/\\-_@])(?:[$+<=>^`||]|" + + re.src_ZPCc + + "))((?![$+<=>^`||])" + + re.tpl_host_port_no_ip_fuzzy_strict + + re.src_path + + ")"; + return re; +} +function assign(obj) { + Array.prototype.slice.call(arguments, 1).forEach(function (source) { + if (!source) return; + Object.keys(source).forEach(function (key) { + obj[key] = source[key]; + }); + }); + return obj; +} +function _class(obj) { + return Object.prototype.toString.call(obj); +} +function isString(obj) { + return _class(obj) === "[object String]"; +} +function isObject(obj) { + return _class(obj) === "[object Object]"; +} +function isRegExp(obj) { + return _class(obj) === "[object RegExp]"; +} +function isFunction(obj) { + return _class(obj) === "[object Function]"; +} +function escapeRE(str) { + return str.replace(/[.?*+^$[\]\\(){}|-]/g, "\\$&"); +} +const defaultOptions = { + fuzzyLink: true, + fuzzyEmail: true, + fuzzyIP: false, +}; +function isOptionsObj(obj) { + return Object.keys(obj || {}).reduce(function (acc, k) { + return acc || defaultOptions.hasOwnProperty(k); + }, false); +} +const defaultSchemas = { + "http:": { + validate: function (text, pos, self) { + const tail = text.slice(pos); + if (!self.re.http) + self.re.http = new RegExp( + "^\\/\\/" + self.re.src_auth + self.re.src_host_port_strict + self.re.src_path, + "i", + ); + if (self.re.http.test(tail)) return tail.match(self.re.http)[0].length; + return 0; + }, + }, + "https:": "http:", + "ftp:": "http:", + "//": { + validate: function (text, pos, self) { + const tail = text.slice(pos); + if (!self.re.no_http) + self.re.no_http = new RegExp( + "^" + + self.re.src_auth + + "(?:localhost|(?:(?:" + + self.re.src_domain + + ")\\.)+" + + self.re.src_domain_root + + ")" + + self.re.src_port + + self.re.src_host_terminator + + self.re.src_path, + "i", + ); + if (self.re.no_http.test(tail)) { + if (pos >= 3 && text[pos - 3] === ":") return 0; + if (pos >= 3 && text[pos - 3] === "/") return 0; + return tail.match(self.re.no_http)[0].length; + } + return 0; + }, + }, + "mailto:": { + validate: function (text, pos, self) { + const tail = text.slice(pos); + if (!self.re.mailto) + self.re.mailto = new RegExp( + "^" + self.re.src_email_name + "@" + self.re.src_host_strict, + "i", + ); + if (self.re.mailto.test(tail)) return tail.match(self.re.mailto)[0].length; + return 0; + }, + }, +}; +const tlds_2ch_src_re = + "a[cdefgilmnoqrstuwxz]|b[abdefghijmnorstvwyz]|c[acdfghiklmnoruvwxyz]|d[ejkmoz]|e[cegrstu]|f[ijkmor]|g[abdefghilmnpqrstuwy]|h[kmnrtu]|i[delmnoqrst]|j[emop]|k[eghimnprwyz]|l[abcikrstuvy]|m[acdeghklmnopqrstuvwxyz]|n[acefgilopruz]|om|p[aefghklmnrstwy]|qa|r[eosuw]|s[abcdeghijklmnortuvxyz]|t[cdfghjklmnortvwz]|u[agksyz]|v[aceginu]|w[fs]|y[et]|z[amw]"; +const tlds_default = + "biz|com|edu|gov|net|org|pro|web|xxx|aero|asia|coop|info|museum|name|shop|рф".split("|"); +function resetScanCache(self) { + self.__index__ = -1; + self.__text_cache__ = ""; +} +function createValidator(re) { + return function (text, pos) { + const tail = text.slice(pos); + if (re.test(tail)) return tail.match(re)[0].length; + return 0; + }; +} +function createNormalizer() { + return function (match, self) { + self.normalize(match); + }; +} +function compile(self) { + const re = (self.re = re_default(self.__opts__)); + const tlds = self.__tlds__.slice(); + self.onCompile(); + if (!self.__tlds_replaced__) tlds.push(tlds_2ch_src_re); + tlds.push(re.src_xn); + re.src_tlds = tlds.join("|"); + function untpl(tpl) { + return tpl.replace("%TLDS%", re.src_tlds); + } + re.email_fuzzy = RegExp(untpl(re.tpl_email_fuzzy), "i"); + re.link_fuzzy = RegExp(untpl(re.tpl_link_fuzzy), "i"); + re.link_no_ip_fuzzy = RegExp(untpl(re.tpl_link_no_ip_fuzzy), "i"); + re.host_fuzzy_test = RegExp(untpl(re.tpl_host_fuzzy_test), "i"); + const aliases = []; + self.__compiled__ = {}; + function schemaError(name, val) { + throw new Error('(LinkifyIt) Invalid schema "' + name + '": ' + val); + } + Object.keys(self.__schemas__).forEach(function (name) { + const val = self.__schemas__[name]; + if (val === null) return; + const compiled = { + validate: null, + link: null, + }; + self.__compiled__[name] = compiled; + if (isObject(val)) { + if (isRegExp(val.validate)) compiled.validate = createValidator(val.validate); + else if (isFunction(val.validate)) compiled.validate = val.validate; + else schemaError(name, val); + if (isFunction(val.normalize)) compiled.normalize = val.normalize; + else if (!val.normalize) compiled.normalize = createNormalizer(); + else schemaError(name, val); + return; + } + if (isString(val)) { + aliases.push(name); + return; + } + schemaError(name, val); + }); + aliases.forEach(function (alias) { + if (!self.__compiled__[self.__schemas__[alias]]) return; + self.__compiled__[alias].validate = self.__compiled__[self.__schemas__[alias]].validate; + self.__compiled__[alias].normalize = self.__compiled__[self.__schemas__[alias]].normalize; + }); + self.__compiled__[""] = { + validate: null, + normalize: createNormalizer(), + }; + const slist = Object.keys(self.__compiled__) + .filter(function (name) { + return name.length > 0 && self.__compiled__[name]; + }) + .map(escapeRE) + .join("|"); + self.re.schema_test = RegExp("(^|(?!_)(?:[><|]|" + re.src_ZPCc + "))(" + slist + ")", "i"); + self.re.schema_search = RegExp("(^|(?!_)(?:[><|]|" + re.src_ZPCc + "))(" + slist + ")", "ig"); + self.re.schema_at_start = RegExp("^" + self.re.schema_search.source, "i"); + self.re.pretest = RegExp( + "(" + self.re.schema_test.source + ")|(" + self.re.host_fuzzy_test.source + ")|@", + "i", + ); + resetScanCache(self); +} +/** + * class Match + * + * Match result. Single element of array, returned by [[LinkifyIt#match]] + **/ +function Match(self, shift) { + const start = self.__index__; + const end = self.__last_index__; + const text = self.__text_cache__.slice(start, end); + /** + * Match#schema -> String + * + * Prefix (protocol) for matched string. + **/ + this.schema = self.__schema__.toLowerCase(); + /** + * Match#index -> Number + * + * First position of matched string. + **/ + this.index = start + shift; + /** + * Match#lastIndex -> Number + * + * Next position after matched string. + **/ + this.lastIndex = end + shift; + /** + * Match#raw -> String + * + * Matched string. + **/ + this.raw = text; + /** + * Match#text -> String + * + * Notmalized text of matched string. + **/ + this.text = text; + /** + * Match#url -> String + * + * Normalized url of matched string. + **/ + this.url = text; +} +function createMatch(self, shift) { + const match = new Match(self, shift); + self.__compiled__[match.schema].normalize(match, self); + return match; +} +/** + * class LinkifyIt + **/ +/** + * new LinkifyIt(schemas, options) + * - schemas (Object): Optional. Additional schemas to validate (prefix/validator) + * - options (Object): { fuzzyLink|fuzzyEmail|fuzzyIP: true|false } + * + * Creates new linkifier instance with optional additional schemas. + * Can be called without `new` keyword for convenience. + * + * By default understands: + * + * - `http(s)://...` , `ftp://...`, `mailto:...` & `//...` links + * - "fuzzy" links and emails (example.com, foo@bar.com). + * + * `schemas` is an object, where each key/value describes protocol/rule: + * + * - __key__ - link prefix (usually, protocol name with `:` at the end, `skype:` + * for example). `linkify-it` makes shure that prefix is not preceeded with + * alphanumeric char and symbols. Only whitespaces and punctuation allowed. + * - __value__ - rule to check tail after link prefix + * - _String_ - just alias to existing rule + * - _Object_ + * - _validate_ - validator function (should return matched length on success), + * or `RegExp`. + * - _normalize_ - optional function to normalize text & url of matched result + * (for example, for @twitter mentions). + * + * `options`: + * + * - __fuzzyLink__ - recognige URL-s without `http(s):` prefix. Default `true`. + * - __fuzzyIP__ - allow IPs in fuzzy links above. Can conflict with some texts + * like version numbers. Default `false`. + * - __fuzzyEmail__ - recognize emails without `mailto:` prefix. + * + **/ +function LinkifyIt(schemas, options) { + if (!(this instanceof LinkifyIt)) return new LinkifyIt(schemas, options); + if (!options) { + if (isOptionsObj(schemas)) { + options = schemas; + schemas = {}; + } + } + this.__opts__ = assign({}, defaultOptions, options); + this.__index__ = -1; + this.__last_index__ = -1; + this.__schema__ = ""; + this.__text_cache__ = ""; + this.__schemas__ = assign({}, defaultSchemas, schemas); + this.__compiled__ = {}; + this.__tlds__ = tlds_default; + this.__tlds_replaced__ = false; + this.re = {}; + compile(this); +} +/** chainable + * LinkifyIt#add(schema, definition) + * - schema (String): rule name (fixed pattern prefix) + * - definition (String|RegExp|Object): schema definition + * + * Add new rule definition. See constructor description for details. + **/ +LinkifyIt.prototype.add = function add(schema, definition) { + this.__schemas__[schema] = definition; + compile(this); + return this; +}; +/** chainable + * LinkifyIt#set(options) + * - options (Object): { fuzzyLink|fuzzyEmail|fuzzyIP: true|false } + * + * Set recognition options for links without schema. + **/ +LinkifyIt.prototype.set = function set(options) { + this.__opts__ = assign(this.__opts__, options); + return this; +}; +/** + * LinkifyIt#test(text) -> Boolean + * + * Searches linkifiable pattern and returns `true` on success or `false` on fail. + **/ +LinkifyIt.prototype.test = function test(text) { + this.__text_cache__ = text; + this.__index__ = -1; + if (!text.length) return false; + let m, ml, me, len, shift, next, re, tld_pos, at_pos; + if (this.re.schema_test.test(text)) { + re = this.re.schema_search; + re.lastIndex = 0; + while ((m = re.exec(text)) !== null) { + len = this.testSchemaAt(text, m[2], re.lastIndex); + if (len) { + this.__schema__ = m[2]; + this.__index__ = m.index + m[1].length; + this.__last_index__ = m.index + m[0].length + len; + break; + } + } + } + if (this.__opts__.fuzzyLink && this.__compiled__["http:"]) { + tld_pos = text.search(this.re.host_fuzzy_test); + if (tld_pos >= 0) { + if (this.__index__ < 0 || tld_pos < this.__index__) { + if ( + (ml = text.match( + this.__opts__.fuzzyIP ? this.re.link_fuzzy : this.re.link_no_ip_fuzzy, + )) !== null + ) { + shift = ml.index + ml[1].length; + if (this.__index__ < 0 || shift < this.__index__) { + this.__schema__ = ""; + this.__index__ = shift; + this.__last_index__ = ml.index + ml[0].length; + } + } + } + } + } + if (this.__opts__.fuzzyEmail && this.__compiled__["mailto:"]) { + at_pos = text.indexOf("@"); + if (at_pos >= 0) { + if ((me = text.match(this.re.email_fuzzy)) !== null) { + shift = me.index + me[1].length; + next = me.index + me[0].length; + if ( + this.__index__ < 0 || + shift < this.__index__ || + (shift === this.__index__ && next > this.__last_index__) + ) { + this.__schema__ = "mailto:"; + this.__index__ = shift; + this.__last_index__ = next; + } + } + } + } + return this.__index__ >= 0; +}; +/** + * LinkifyIt#pretest(text) -> Boolean + * + * Very quick check, that can give false positives. Returns true if link MAY BE + * can exists. Can be used for speed optimization, when you need to check that + * link NOT exists. + **/ +LinkifyIt.prototype.pretest = function pretest(text) { + return this.re.pretest.test(text); +}; +/** + * LinkifyIt#testSchemaAt(text, name, position) -> Number + * - text (String): text to scan + * - name (String): rule (schema) name + * - position (Number): text offset to check from + * + * Similar to [[LinkifyIt#test]] but checks only specific protocol tail exactly + * at given position. Returns length of found pattern (0 on fail). + **/ +LinkifyIt.prototype.testSchemaAt = function testSchemaAt(text, schema, pos) { + if (!this.__compiled__[schema.toLowerCase()]) return 0; + return this.__compiled__[schema.toLowerCase()].validate(text, pos, this); +}; +/** + * LinkifyIt#match(text) -> Array|null + * + * Returns array of found link descriptions or `null` on fail. We strongly + * recommend to use [[LinkifyIt#test]] first, for best speed. + * + * ##### Result match description + * + * - __schema__ - link schema, can be empty for fuzzy links, or `//` for + * protocol-neutral links. + * - __index__ - offset of matched text + * - __lastIndex__ - index of next char after mathch end + * - __raw__ - matched text + * - __text__ - normalized text + * - __url__ - link, generated from matched text + **/ +LinkifyIt.prototype.match = function match(text) { + const result = []; + let shift = 0; + if (this.__index__ >= 0 && this.__text_cache__ === text) { + result.push(createMatch(this, shift)); + shift = this.__last_index__; + } + let tail = shift ? text.slice(shift) : text; + while (this.test(tail)) { + result.push(createMatch(this, shift)); + tail = tail.slice(this.__last_index__); + shift += this.__last_index__; + } + if (result.length) return result; + return null; +}; +/** + * LinkifyIt#matchAtStart(text) -> Match|null + * + * Returns fully-formed (not fuzzy) link if it starts at the beginning + * of the string, and null otherwise. + **/ +LinkifyIt.prototype.matchAtStart = function matchAtStart(text) { + this.__text_cache__ = text; + this.__index__ = -1; + if (!text.length) return null; + const m = this.re.schema_at_start.exec(text); + if (!m) return null; + const len = this.testSchemaAt(text, m[2], m[0].length); + if (!len) return null; + this.__schema__ = m[2]; + this.__index__ = m.index + m[1].length; + this.__last_index__ = m.index + m[0].length + len; + return createMatch(this, 0); +}; +/** chainable + * LinkifyIt#tlds(list [, keepOld]) -> this + * - list (Array): list of tlds + * - keepOld (Boolean): merge with current list if `true` (`false` by default) + * + * Load (or merge) new tlds list. Those are user for fuzzy links (without prefix) + * to avoid false positives. By default this algorythm used: + * + * - hostname with any 2-letter root zones are ok. + * - biz|com|edu|gov|net|org|pro|web|xxx|aero|asia|coop|info|museum|name|shop|рф + * are ok. + * - encoded (`xn--...`) root zones are ok. + * + * If list is replaced, then exact match for 2-chars root zones will be checked. + **/ +LinkifyIt.prototype.tlds = function tlds(list, keepOld) { + list = Array.isArray(list) ? list : [list]; + if (!keepOld) { + this.__tlds__ = list.slice(); + this.__tlds_replaced__ = true; + compile(this); + return this; + } + this.__tlds__ = this.__tlds__ + .concat(list) + .sort() + .filter(function (el, idx, arr) { + return el !== arr[idx - 1]; + }) + .reverse(); + compile(this); + return this; +}; +/** + * LinkifyIt#normalize(match) + * + * Default normalizer (if schema does not define it's own). + **/ +LinkifyIt.prototype.normalize = function normalize(match) { + if (!match.schema) match.url = "http://" + match.url; + if (match.schema === "mailto:" && !/^mailto:/i.test(match.url)) match.url = "mailto:" + match.url; +}; +/** + * LinkifyIt#onCompile() + * + * Override to modify basic RegExp-s. + **/ +LinkifyIt.prototype.onCompile = function onCompile() {}; +/** Highest positive signed 32-bit float value */ +const maxInt = 2147483647; +/** Bootstring parameters */ +const base = 36; +const tMin = 1; +const tMax = 26; +const skew = 38; +const damp = 700; +const initialBias = 72; +const initialN = 128; +const delimiter = "-"; +/** Regular expressions */ +const regexPunycode = /^xn--/; +const regexNonASCII = /[^\0-\x7F]/; +const regexSeparators = /[\x2E\u3002\uFF0E\uFF61]/g; +/** Error messages */ +const errors = { + overflow: "Overflow: input needs wider integers to process", + "not-basic": "Illegal input >= 0x80 (not a basic code point)", + "invalid-input": "Invalid input", +}; +/** Convenience shortcuts */ +const baseMinusTMin = base - tMin; +const floor = Math.floor; +const stringFromCharCode = String.fromCharCode; +/** + * A generic error utility function. + * @private + * @param {String} type The error type. + * @returns {Error} Throws a `RangeError` with the applicable error message. + */ +function error(type) { + throw new RangeError(errors[type]); +} +/** + * A generic `Array#map` utility function. + * @private + * @param {Array} array The array to iterate over. + * @param {Function} callback The function that gets called for every array + * item. + * @returns {Array} A new array of values returned by the callback function. + */ +function map(array, callback) { + const result = []; + let length = array.length; + while (length--) result[length] = callback(array[length]); + return result; +} +/** + * A simple `Array#map`-like wrapper to work with domain name strings or email + * addresses. + * @private + * @param {String} domain The domain name or email address. + * @param {Function} callback The function that gets called for every + * character. + * @returns {String} A new string of characters returned by the callback + * function. + */ +function mapDomain(domain, callback) { + const parts = domain.split("@"); + let result = ""; + if (parts.length > 1) { + result = parts[0] + "@"; + domain = parts[1]; + } + domain = domain.replace(regexSeparators, "."); + const encoded = map(domain.split("."), callback).join("."); + return result + encoded; +} +/** + * Creates an array containing the numeric code points of each Unicode + * character in the string. While JavaScript uses UCS-2 internally, + * this function will convert a pair of surrogate halves (each of which + * UCS-2 exposes as separate characters) into a single code point, + * matching UTF-16. + * @see `punycode.ucs2.encode` + * @see + * @memberOf punycode.ucs2 + * @name decode + * @param {String} string The Unicode input string (UCS-2). + * @returns {Array} The new array of code points. + */ +function ucs2decode(string) { + const output = []; + let counter = 0; + const length = string.length; + while (counter < length) { + const value = string.charCodeAt(counter++); + if (value >= 55296 && value <= 56319 && counter < length) { + const extra = string.charCodeAt(counter++); + if ((extra & 64512) == 56320) output.push(((value & 1023) << 10) + (extra & 1023) + 65536); + else { + output.push(value); + counter--; + } + } else output.push(value); + } + return output; +} +/** + * Creates a string based on an array of numeric code points. + * @see `punycode.ucs2.decode` + * @memberOf punycode.ucs2 + * @name encode + * @param {Array} codePoints The array of numeric code points. + * @returns {String} The new Unicode string (UCS-2). + */ +const ucs2encode = (codePoints) => String.fromCodePoint(...codePoints); +/** + * Converts a basic code point into a digit/integer. + * @see `digitToBasic()` + * @private + * @param {Number} codePoint The basic numeric code point value. + * @returns {Number} The numeric value of a basic code point (for use in + * representing integers) in the range `0` to `base - 1`, or `base` if + * the code point does not represent a value. + */ +const basicToDigit = function (codePoint) { + if (codePoint >= 48 && codePoint < 58) return 26 + (codePoint - 48); + if (codePoint >= 65 && codePoint < 91) return codePoint - 65; + if (codePoint >= 97 && codePoint < 123) return codePoint - 97; + return base; +}; +/** + * Converts a digit/integer into a basic code point. + * @see `basicToDigit()` + * @private + * @param {Number} digit The numeric value of a basic code point. + * @returns {Number} The basic code point whose value (when used for + * representing integers) is `digit`, which needs to be in the range + * `0` to `base - 1`. If `flag` is non-zero, the uppercase form is + * used; else, the lowercase form is used. The behavior is undefined + * if `flag` is non-zero and `digit` has no uppercase form. + */ +const digitToBasic = function (digit, flag) { + return digit + 22 + 75 * (digit < 26) - ((flag != 0) << 5); +}; +/** + * Bias adaptation function as per section 3.4 of RFC 3492. + * https://tools.ietf.org/html/rfc3492#section-3.4 + * @private + */ +const adapt = function (delta, numPoints, firstTime) { + let k = 0; + delta = firstTime ? floor(delta / damp) : delta >> 1; + delta += floor(delta / numPoints); + for (; delta > (baseMinusTMin * tMax) >> 1; k += base) delta = floor(delta / baseMinusTMin); + return floor(k + ((baseMinusTMin + 1) * delta) / (delta + skew)); +}; +/** + * Converts a Punycode string of ASCII-only symbols to a string of Unicode + * symbols. + * @memberOf punycode + * @param {String} input The Punycode string of ASCII-only symbols. + * @returns {String} The resulting string of Unicode symbols. + */ +const decode = function (input) { + const output = []; + const inputLength = input.length; + let i = 0; + let n = initialN; + let bias = initialBias; + let basic = input.lastIndexOf(delimiter); + if (basic < 0) basic = 0; + for (let j = 0; j < basic; ++j) { + if (input.charCodeAt(j) >= 128) error("not-basic"); + output.push(input.charCodeAt(j)); + } + for (let index = basic > 0 ? basic + 1 : 0; index < inputLength; ) { + const oldi = i; + for (let w = 1, k = base; ; k += base) { + if (index >= inputLength) error("invalid-input"); + const digit = basicToDigit(input.charCodeAt(index++)); + if (digit >= base) error("invalid-input"); + if (digit > floor((maxInt - i) / w)) error("overflow"); + i += digit * w; + const t = k <= bias ? tMin : k >= bias + tMax ? tMax : k - bias; + if (digit < t) break; + const baseMinusT = base - t; + if (w > floor(maxInt / baseMinusT)) error("overflow"); + w *= baseMinusT; + } + const out = output.length + 1; + bias = adapt(i - oldi, out, oldi == 0); + if (floor(i / out) > maxInt - n) error("overflow"); + n += floor(i / out); + i %= out; + output.splice(i++, 0, n); + } + return String.fromCodePoint(...output); +}; +/** + * Converts a string of Unicode symbols (e.g. a domain name label) to a + * Punycode string of ASCII-only symbols. + * @memberOf punycode + * @param {String} input The string of Unicode symbols. + * @returns {String} The resulting Punycode string of ASCII-only symbols. + */ +const encode = function (input) { + const output = []; + input = ucs2decode(input); + const inputLength = input.length; + let n = initialN; + let delta = 0; + let bias = initialBias; + for (const currentValue of input) + if (currentValue < 128) output.push(stringFromCharCode(currentValue)); + const basicLength = output.length; + let handledCPCount = basicLength; + if (basicLength) output.push(delimiter); + while (handledCPCount < inputLength) { + let m = maxInt; + for (const currentValue of input) if (currentValue >= n && currentValue < m) m = currentValue; + const handledCPCountPlusOne = handledCPCount + 1; + if (m - n > floor((maxInt - delta) / handledCPCountPlusOne)) error("overflow"); + delta += (m - n) * handledCPCountPlusOne; + n = m; + for (const currentValue of input) { + if (currentValue < n && ++delta > maxInt) error("overflow"); + if (currentValue === n) { + let q = delta; + for (let k = base; ; k += base) { + const t = k <= bias ? tMin : k >= bias + tMax ? tMax : k - bias; + if (q < t) break; + const qMinusT = q - t; + const baseMinusT = base - t; + output.push(stringFromCharCode(digitToBasic(t + (qMinusT % baseMinusT), 0))); + q = floor(qMinusT / baseMinusT); + } + output.push(stringFromCharCode(digitToBasic(q, 0))); + bias = adapt(delta, handledCPCountPlusOne, handledCPCount === basicLength); + delta = 0; + ++handledCPCount; + } + } + ++delta; + ++n; + } + return output.join(""); +}; +/** + * Converts a Punycode string representing a domain name or an email address + * to Unicode. Only the Punycoded parts of the input will be converted, i.e. + * it doesn't matter if you call it on a string that has already been + * converted to Unicode. + * @memberOf punycode + * @param {String} input The Punycoded domain name or email address to + * convert to Unicode. + * @returns {String} The Unicode representation of the given Punycode + * string. + */ +const toUnicode = function (input) { + return mapDomain(input, function (string) { + return regexPunycode.test(string) ? decode(string.slice(4).toLowerCase()) : string; + }); +}; +/** + * Converts a Unicode string representing a domain name or an email address to + * Punycode. Only the non-ASCII parts of the domain name will be converted, + * i.e. it doesn't matter if you call it with a domain that's already in + * ASCII. + * @memberOf punycode + * @param {String} input The domain name or email address to convert, as a + * Unicode string. + * @returns {String} The Punycode representation of the given domain name or + * email address. + */ +const toASCII = function (input) { + return mapDomain(input, function (string) { + return regexNonASCII.test(string) ? "xn--" + encode(string) : string; + }); +}; +/** Define the public API */ +const punycode = { + version: "2.3.1", + ucs2: { + decode: ucs2decode, + encode: ucs2encode, + }, + decode: decode, + encode: encode, + toASCII: toASCII, + toUnicode: toUnicode, +}; +const config = { + default: { + options: { + html: false, + xhtmlOut: false, + breaks: false, + langPrefix: "language-", + linkify: false, + typographer: false, + quotes: "“”‘’", + highlight: null, + maxNesting: 100, + }, + components: { + core: {}, + block: {}, + inline: {}, + }, + }, + zero: { + options: { + html: false, + xhtmlOut: false, + breaks: false, + langPrefix: "language-", + linkify: false, + typographer: false, + quotes: "“”‘’", + highlight: null, + maxNesting: 20, + }, + components: { + core: { rules: ["normalize", "block", "inline", "text_join"] }, + block: { rules: ["paragraph"] }, + inline: { + rules: ["text"], + rules2: ["balance_pairs", "fragments_join"], + }, + }, + }, + commonmark: { + options: { + html: true, + xhtmlOut: true, + breaks: false, + langPrefix: "language-", + linkify: false, + typographer: false, + quotes: "“”‘’", + highlight: null, + maxNesting: 20, + }, + components: { + core: { rules: ["normalize", "block", "inline", "text_join"] }, + block: { + rules: [ + "blockquote", + "code", + "fence", + "heading", + "hr", + "html_block", + "lheading", + "list", + "reference", + "paragraph", + ], + }, + inline: { + rules: [ + "autolink", + "backticks", + "emphasis", + "entity", + "escape", + "html_inline", + "image", + "link", + "newline", + "text", + ], + rules2: ["balance_pairs", "emphasis", "fragments_join"], + }, + }, + }, +}; +const BAD_PROTO_RE = /^(vbscript|javascript|file|data):/; +const GOOD_DATA_RE = /^data:image\/(gif|png|jpeg|webp);/; +function validateLink(url) { + const str = url.trim().toLowerCase(); + return BAD_PROTO_RE.test(str) ? GOOD_DATA_RE.test(str) : true; +} +const RECODE_HOSTNAME_FOR = ["http:", "https:", "mailto:"]; +function normalizeLink(url) { + const parsed = urlParse(url, true); + if (parsed.hostname) { + if (!parsed.protocol || RECODE_HOSTNAME_FOR.indexOf(parsed.protocol) >= 0) + try { + parsed.hostname = punycode.toASCII(parsed.hostname); + } catch (er) {} + } + return encode$2(format(parsed)); +} +function normalizeLinkText(url) { + const parsed = urlParse(url, true); + if (parsed.hostname) { + if (!parsed.protocol || RECODE_HOSTNAME_FOR.indexOf(parsed.protocol) >= 0) + try { + parsed.hostname = punycode.toUnicode(parsed.hostname); + } catch (er) {} + } + return decode$2(format(parsed), decode$2.defaultChars + "%"); +} +/** + * class MarkdownIt + * + * Main parser/renderer class. + * + * ##### Usage + * + * ```javascript + * // node.js, "classic" way: + * var MarkdownIt = require('markdown-it'), + * md = new MarkdownIt(); + * var result = md.render('# markdown-it rulezz!'); + * + * // node.js, the same, but with sugar: + * var md = require('markdown-it')(); + * var result = md.render('# markdown-it rulezz!'); + * + * // browser without AMD, added to "window" on script load + * // Note, there are no dash. + * var md = window.markdownit(); + * var result = md.render('# markdown-it rulezz!'); + * ``` + * + * Single line rendering, without paragraph wrap: + * + * ```javascript + * var md = require('markdown-it')(); + * var result = md.renderInline('__markdown-it__ rulezz!'); + * ``` + **/ +/** + * new MarkdownIt([presetName, options]) + * - presetName (String): optional, `commonmark` / `zero` + * - options (Object) + * + * Creates parser instanse with given config. Can be called without `new`. + * + * ##### presetName + * + * MarkdownIt provides named presets as a convenience to quickly + * enable/disable active syntax rules and options for common use cases. + * + * - ["commonmark"](https://github.com/markdown-it/markdown-it/blob/master/lib/presets/commonmark.mjs) - + * configures parser to strict [CommonMark](http://commonmark.org/) mode. + * - [default](https://github.com/markdown-it/markdown-it/blob/master/lib/presets/default.mjs) - + * similar to GFM, used when no preset name given. Enables all available rules, + * but still without html, typographer & autolinker. + * - ["zero"](https://github.com/markdown-it/markdown-it/blob/master/lib/presets/zero.mjs) - + * all rules disabled. Useful to quickly setup your config via `.enable()`. + * For example, when you need only `bold` and `italic` markup and nothing else. + * + * ##### options: + * + * - __html__ - `false`. Set `true` to enable HTML tags in source. Be careful! + * That's not safe! You may need external sanitizer to protect output from XSS. + * It's better to extend features via plugins, instead of enabling HTML. + * - __xhtmlOut__ - `false`. Set `true` to add '/' when closing single tags + * (`
`). This is needed only for full CommonMark compatibility. In real + * world you will need HTML output. + * - __breaks__ - `false`. Set `true` to convert `\n` in paragraphs into `
`. + * - __langPrefix__ - `language-`. CSS language class prefix for fenced blocks. + * Can be useful for external highlighters. + * - __linkify__ - `false`. Set `true` to autoconvert URL-like text to links. + * - __typographer__ - `false`. Set `true` to enable [some language-neutral + * replacement](https://github.com/markdown-it/markdown-it/blob/master/lib/rules_core/replacements.mjs) + + * quotes beautification (smartquotes). + * - __quotes__ - `“”‘’`, String or Array. Double + single quotes replacement + * pairs, when typographer enabled and smartquotes on. For example, you can + * use `'«»„“'` for Russian, `'„“‚‘'` for German, and + * `['«\xA0', '\xA0»', '‹\xA0', '\xA0›']` for French (including nbsp). + * - __highlight__ - `null`. Highlighter function for fenced code blocks. + * Highlighter `function (str, lang)` should return escaped HTML. It can also + * return empty string if the source was not changed and should be escaped + * externaly. If result starts with ` or ``): + * + * ```javascript + * var hljs = require('highlight.js') // https://highlightjs.org/ + * + * // Actual default values + * var md = require('markdown-it')({ + * highlight: function (str, lang) { + * if (lang && hljs.getLanguage(lang)) { + * try { + * return '
' +
+ *                hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
+ *                '
'; + * } catch (__) {} + * } + * + * return '
' + md.utils.escapeHtml(str) + '
'; + * } + * }); + * ``` + * + **/ +function MarkdownIt(presetName, options) { + if (!(this instanceof MarkdownIt)) return new MarkdownIt(presetName, options); + if (!options) { + if (!isString$1(presetName)) { + options = presetName || {}; + presetName = "default"; + } + } + /** + * MarkdownIt#inline -> ParserInline + * + * Instance of [[ParserInline]]. You may need it to add new rules when + * writing plugins. For simple rules control use [[MarkdownIt.disable]] and + * [[MarkdownIt.enable]]. + **/ + this.inline = new ParserInline(); + /** + * MarkdownIt#block -> ParserBlock + * + * Instance of [[ParserBlock]]. You may need it to add new rules when + * writing plugins. For simple rules control use [[MarkdownIt.disable]] and + * [[MarkdownIt.enable]]. + **/ + this.block = new ParserBlock(); + /** + * MarkdownIt#core -> Core + * + * Instance of [[Core]] chain executor. You may need it to add new rules when + * writing plugins. For simple rules control use [[MarkdownIt.disable]] and + * [[MarkdownIt.enable]]. + **/ + this.core = new Core(); + /** + * MarkdownIt#renderer -> Renderer + * + * Instance of [[Renderer]]. Use it to modify output look. Or to add rendering + * rules for new token types, generated by plugins. + * + * ##### Example + * + * ```javascript + * var md = require('markdown-it')(); + * + * function myToken(tokens, idx, options, env, self) { + * //... + * return result; + * }; + * + * md.renderer.rules['my_token'] = myToken + * ``` + * + * See [[Renderer]] docs and [source code](https://github.com/markdown-it/markdown-it/blob/master/lib/renderer.mjs). + **/ + this.renderer = new Renderer(); + /** + * MarkdownIt#linkify -> LinkifyIt + * + * [linkify-it](https://github.com/markdown-it/linkify-it) instance. + * Used by [linkify](https://github.com/markdown-it/markdown-it/blob/master/lib/rules_core/linkify.mjs) + * rule. + **/ + this.linkify = new LinkifyIt(); + /** + * MarkdownIt#validateLink(url) -> Boolean + * + * Link validation function. CommonMark allows too much in links. By default + * we disable `javascript:`, `vbscript:`, `file:` schemas, and almost all `data:...` schemas + * except some embedded image types. + * + * You can change this behaviour: + * + * ```javascript + * var md = require('markdown-it')(); + * // enable everything + * md.validateLink = function () { return true; } + * ``` + **/ + this.validateLink = validateLink; + /** + * MarkdownIt#normalizeLink(url) -> String + * + * Function used to encode link url to a machine-readable format, + * which includes url-encoding, punycode, etc. + **/ + this.normalizeLink = normalizeLink; + /** + * MarkdownIt#normalizeLinkText(url) -> String + * + * Function used to decode link url to a human-readable format` + **/ + this.normalizeLinkText = normalizeLinkText; + /** + * MarkdownIt#utils -> utils + * + * Assorted utility functions, useful to write plugins. See details + * [here](https://github.com/markdown-it/markdown-it/blob/master/lib/common/utils.mjs). + **/ + this.utils = utils_exports; + /** + * MarkdownIt#helpers -> helpers + * + * Link components parser functions, useful to write plugins. See details + * [here](https://github.com/markdown-it/markdown-it/blob/master/lib/helpers). + **/ + this.helpers = assign$1({}, helpers_exports); + this.options = {}; + this.configure(presetName); + if (options) this.set(options); +} +/** chainable + * MarkdownIt.set(options) + * + * Set parser options (in the same format as in constructor). Probably, you + * will never need it, but you can change options after constructor call. + * + * ##### Example + * + * ```javascript + * var md = require('markdown-it')() + * .set({ html: true, breaks: true }) + * .set({ typographer, true }); + * ``` + * + * __Note:__ To achieve the best possible performance, don't modify a + * `markdown-it` instance options on the fly. If you need multiple configurations + * it's best to create multiple instances and initialize each with separate + * config. + **/ +MarkdownIt.prototype.set = function (options) { + assign$1(this.options, options); + return this; +}; +/** chainable, internal + * MarkdownIt.configure(presets) + * + * Batch load of all options and compenent settings. This is internal method, + * and you probably will not need it. But if you will - see available presets + * and data structure [here](https://github.com/markdown-it/markdown-it/tree/master/lib/presets) + * + * We strongly recommend to use presets instead of direct config loads. That + * will give better compatibility with next versions. + **/ +MarkdownIt.prototype.configure = function (presets) { + const self = this; + if (isString$1(presets)) { + const presetName = presets; + presets = config[presetName]; + if (!presets) throw new Error('Wrong `markdown-it` preset "' + presetName + '", check name'); + } + if (!presets) throw new Error("Wrong `markdown-it` preset, can't be empty"); + if (presets.options) self.set(presets.options); + if (presets.components) + Object.keys(presets.components).forEach(function (name) { + if (presets.components[name].rules) + self[name].ruler.enableOnly(presets.components[name].rules); + if (presets.components[name].rules2) + self[name].ruler2.enableOnly(presets.components[name].rules2); + }); + return this; +}; +/** chainable + * MarkdownIt.enable(list, ignoreInvalid) + * - list (String|Array): rule name or list of rule names to enable + * - ignoreInvalid (Boolean): set `true` to ignore errors when rule not found. + * + * Enable list or rules. It will automatically find appropriate components, + * containing rules with given names. If rule not found, and `ignoreInvalid` + * not set - throws exception. + * + * ##### Example + * + * ```javascript + * var md = require('markdown-it')() + * .enable(['sub', 'sup']) + * .disable('smartquotes'); + * ``` + **/ +MarkdownIt.prototype.enable = function (list, ignoreInvalid) { + let result = []; + if (!Array.isArray(list)) list = [list]; + ["core", "block", "inline"].forEach(function (chain) { + result = result.concat(this[chain].ruler.enable(list, true)); + }, this); + result = result.concat(this.inline.ruler2.enable(list, true)); + const missed = list.filter(function (name) { + return result.indexOf(name) < 0; + }); + if (missed.length && !ignoreInvalid) + throw new Error("MarkdownIt. Failed to enable unknown rule(s): " + missed); + return this; +}; +/** chainable + * MarkdownIt.disable(list, ignoreInvalid) + * - list (String|Array): rule name or list of rule names to disable. + * - ignoreInvalid (Boolean): set `true` to ignore errors when rule not found. + * + * The same as [[MarkdownIt.enable]], but turn specified rules off. + **/ +MarkdownIt.prototype.disable = function (list, ignoreInvalid) { + let result = []; + if (!Array.isArray(list)) list = [list]; + ["core", "block", "inline"].forEach(function (chain) { + result = result.concat(this[chain].ruler.disable(list, true)); + }, this); + result = result.concat(this.inline.ruler2.disable(list, true)); + const missed = list.filter(function (name) { + return result.indexOf(name) < 0; + }); + if (missed.length && !ignoreInvalid) + throw new Error("MarkdownIt. Failed to disable unknown rule(s): " + missed); + return this; +}; +/** chainable + * MarkdownIt.use(plugin, params) + * + * Load specified plugin with given params into current parser instance. + * It's just a sugar to call `plugin(md, params)` with curring. + * + * ##### Example + * + * ```javascript + * var iterator = require('markdown-it-for-inline'); + * var md = require('markdown-it')() + * .use(iterator, 'foo_replace', 'text', function (tokens, idx) { + * tokens[idx].content = tokens[idx].content.replace(/foo/g, 'bar'); + * }); + * ``` + **/ +MarkdownIt.prototype.use = function (plugin) { + const args = [this].concat(Array.prototype.slice.call(arguments, 1)); + plugin.apply(plugin, args); + return this; +}; +/** internal + * MarkdownIt.parse(src, env) -> Array + * - src (String): source string + * - env (Object): environment sandbox + * + * Parse input string and return list of block tokens (special token type + * "inline" will contain list of inline tokens). You should not call this + * method directly, until you write custom renderer (for example, to produce + * AST). + * + * `env` is used to pass data between "distributed" rules and return additional + * metadata like reference info, needed for the renderer. It also can be used to + * inject data in specific cases. Usually, you will be ok to pass `{}`, + * and then pass updated object to renderer. + **/ +MarkdownIt.prototype.parse = function (src, env) { + if (typeof src !== "string") throw new Error("Input data should be a String"); + const state = new this.core.State(src, this, env); + this.core.process(state); + return state.tokens; +}; +/** + * MarkdownIt.render(src [, env]) -> String + * - src (String): source string + * - env (Object): environment sandbox + * + * Render markdown string into html. It does all magic for you :). + * + * `env` can be used to inject additional metadata (`{}` by default). + * But you will not need it with high probability. See also comment + * in [[MarkdownIt.parse]]. + **/ +MarkdownIt.prototype.render = function (src, env) { + env = env || {}; + return this.renderer.render(this.parse(src, env), this.options, env); +}; +/** internal + * MarkdownIt.parseInline(src, env) -> Array + * - src (String): source string + * - env (Object): environment sandbox + * + * The same as [[MarkdownIt.parse]] but skip all block rules. It returns the + * block tokens list with the single `inline` element, containing parsed inline + * tokens in `children` property. Also updates `env` object. + **/ +MarkdownIt.prototype.parseInline = function (src, env) { + const state = new this.core.State(src, this, env); + state.inlineMode = true; + this.core.process(state); + return state.tokens; +}; +/** + * MarkdownIt.renderInline(src [, env]) -> String + * - src (String): source string + * - env (Object): environment sandbox + * + * Similar to [[MarkdownIt.render]] but for single paragraph content. Result + * will NOT be wrapped into `

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

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

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

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