From 0b58a1cc1304f00009edba73e599362f7cb593cb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 22:41:30 -0700 Subject: [PATCH 01/23] fix: stabilize windows parallels smoke harness --- scripts/e2e/parallels-windows-smoke.sh | 29 ++++++++++++-------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/scripts/e2e/parallels-windows-smoke.sh b/scripts/e2e/parallels-windows-smoke.sh index cd144511f49..e7016d22062 100644 --- a/scripts/e2e/parallels-windows-smoke.sh +++ b/scripts/e2e/parallels-windows-smoke.sh @@ -371,9 +371,10 @@ phase_run() { local timeout_s="$2" shift 2 - local log_path pid rc timed_out + local log_path pid start rc timed_out log_path="$(phase_log_path "$phase_id")" say "$phase_id" + start=$SECONDS timed_out=0 ( @@ -381,26 +382,22 @@ phase_run() { ) >"$log_path" 2>&1 & pid=$! - ( - sleep "$timeout_s" - kill "$pid" >/dev/null 2>&1 || true - sleep 2 - kill -9 "$pid" >/dev/null 2>&1 || true - ) & - local killer_pid=$! + while kill -0 "$pid" >/dev/null 2>&1; do + if (( SECONDS - start >= timeout_s )); then + timed_out=1 + kill "$pid" >/dev/null 2>&1 || true + sleep 2 + kill -9 "$pid" >/dev/null 2>&1 || true + break + fi + sleep 1 + done set +e wait "$pid" rc=$? set -e - if kill -0 "$killer_pid" >/dev/null 2>&1; then - kill "$killer_pid" >/dev/null 2>&1 || true - wait "$killer_pid" >/dev/null 2>&1 || true - else - timed_out=1 - fi - if (( timed_out )); then warn "$phase_id timed out after ${timeout_s}s" printf 'timeout after %ss\n' "$timeout_s" >>"$log_path" @@ -770,7 +767,7 @@ show_gateway_status_compat() { } verify_turn() { - guest_run_openclaw "" "" agent --agent main --message ping --json + guest_run_openclaw "" "" agent --agent main --message "Reply with exact ASCII text OK only." --json } capture_latest_ref_failure() { From 55cbfb6e6ad17dcc3c512e3c6170f00a83451eb5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 22:42:58 -0700 Subject: [PATCH 02/23] refactor(plugins): move provider onboarding auth into plugins --- extensions/anthropic/index.ts | 37 ++++- extensions/google/index.ts | 28 +++- extensions/minimax/index.ts | 70 +++++++- extensions/openai/openai-provider.ts | 28 +++- .../auth-choice.apply.plugin-provider.ts | 4 + src/commands/onboard-auth.credentials.ts | 2 +- .../local/auth-choice.test.ts | 8 +- .../local/auth-choice.ts | 36 ++--- src/plugins/provider-api-key-auth.ts | 152 ++++++++++++++++++ src/plugins/provider-wizard.test.ts | 40 +++++ src/plugins/provider-wizard.ts | 32 ++++ src/plugins/types.ts | 17 ++ 12 files changed, 420 insertions(+), 34 deletions(-) create mode 100644 src/plugins/provider-api-key-auth.ts diff --git a/extensions/anthropic/index.ts b/extensions/anthropic/index.ts index ba2a1a55cb5..13758e7de46 100644 --- a/extensions/anthropic/index.ts +++ b/extensions/anthropic/index.ts @@ -19,10 +19,12 @@ import { import { buildTokenProfileId, validateAnthropicSetupToken } from "../../src/commands/auth-token.js"; import { applyAuthProfileConfig } from "../../src/commands/onboard-auth.js"; import { fetchClaudeUsage } from "../../src/infra/provider-usage.fetch.js"; +import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import type { ProviderAuthResult } from "../../src/plugins/types.js"; import { normalizeSecretInput } from "../../src/utils/normalize-secret-input.js"; const PROVIDER_ID = "anthropic"; +const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-sonnet-4-6"; 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; @@ -313,6 +315,14 @@ const anthropicPlugin = { label: "setup-token (claude)", hint: "Paste a setup-token from `claude setup-token`", kind: "token", + wizard: { + choiceId: "token", + choiceLabel: "Anthropic token (paste setup-token)", + choiceHint: "Run `claude setup-token` elsewhere, then paste the token here", + groupId: "anthropic", + groupLabel: "Anthropic", + groupHint: "setup-token + API key", + }, run: async (ctx: ProviderAuthContext) => await runAnthropicSetupToken(ctx), runNonInteractive: async (ctx) => await runAnthropicSetupTokenNonInteractive({ @@ -322,15 +332,26 @@ const anthropicPlugin = { agentDir: ctx.agentDir, }), }, + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "Anthropic API key", + hint: "Direct Anthropic API key", + optionKey: "anthropicApiKey", + flagName: "--anthropic-api-key", + envVar: "ANTHROPIC_API_KEY", + promptMessage: "Enter Anthropic API key", + defaultModel: DEFAULT_ANTHROPIC_MODEL, + expectedProviders: ["anthropic"], + wizard: { + choiceId: "apiKey", + choiceLabel: "Anthropic API key", + groupId: "anthropic", + groupLabel: "Anthropic", + groupHint: "setup-token + API key", + }, + }), ], - wizard: { - setup: { - choiceId: "token", - choiceLabel: "Anthropic token (paste setup-token)", - choiceHint: "Run `claude setup-token` elsewhere, then paste the token here", - methodId: "setup-token", - }, - }, resolveDynamicModel: (ctx) => resolveAnthropicForwardCompatModel(ctx), capabilities: { providerFamily: "anthropic", diff --git a/extensions/google/index.ts b/extensions/google/index.ts index 0afa07e2ce0..59d417e9349 100644 --- a/extensions/google/index.ts +++ b/extensions/google/index.ts @@ -3,7 +3,12 @@ import { getScopedCredentialValue, setScopedCredentialValue, } from "../../src/agents/tools/web-search-plugin-factory.js"; +import { + GOOGLE_GEMINI_DEFAULT_MODEL, + applyGoogleGeminiModelDefault, +} from "../../src/commands/google-gemini-model-default.js"; import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; +import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import type { OpenClawPluginApi } from "../../src/plugins/types.js"; import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js"; import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js"; @@ -19,7 +24,28 @@ const googlePlugin = { label: "Google AI Studio", docsPath: "/providers/models", envVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: "google", + methodId: "api-key", + label: "Google Gemini API key", + hint: "AI Studio / Gemini API key", + optionKey: "geminiApiKey", + flagName: "--gemini-api-key", + envVar: "GEMINI_API_KEY", + promptMessage: "Enter Gemini API key", + defaultModel: GOOGLE_GEMINI_DEFAULT_MODEL, + expectedProviders: ["google"], + applyConfig: (cfg) => applyGoogleGeminiModelDefault(cfg).next, + wizard: { + choiceId: "gemini-api-key", + choiceLabel: "Google Gemini API key", + groupId: "google", + groupLabel: "Google", + groupHint: "Gemini API key + OAuth", + }, + }), + ], resolveDynamicModel: (ctx) => resolveGoogle31ForwardCompatModel({ providerId: "google", ctx }), isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId), diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index 0231fd86236..6906bb0438d 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -12,7 +12,12 @@ import { buildMinimaxPortalProvider, buildMinimaxProvider, } from "../../src/agents/models-config.providers.static.js"; +import { + applyMinimaxApiConfig, + applyMinimaxApiConfigCn, +} from "../../src/commands/onboard-auth.config-minimax.js"; import { fetchMinimaxUsage } from "../../src/infra/provider-usage.fetch.js"; +import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import { loginMiniMaxPortalOAuth, type MiniMaxRegion } from "./oauth.js"; const API_PROVIDER_ID = "minimax"; @@ -160,7 +165,54 @@ const minimaxPlugin = { label: PROVIDER_LABEL, docsPath: "/providers/minimax", envVars: ["MINIMAX_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: API_PROVIDER_ID, + methodId: "api-global", + label: "MiniMax API key (Global)", + hint: "Global endpoint - api.minimax.io", + optionKey: "minimaxApiKey", + flagName: "--minimax-api-key", + envVar: "MINIMAX_API_KEY", + promptMessage: + "Enter MiniMax API key (sk-api- or sk-cp-)\nhttps://platform.minimax.io/user-center/basic-information/interface-key", + profileId: "minimax:global", + defaultModel: modelRef(DEFAULT_MODEL), + expectedProviders: ["minimax"], + applyConfig: (cfg) => applyMinimaxApiConfig(cfg), + wizard: { + choiceId: "minimax-global-api", + choiceLabel: "MiniMax API key (Global)", + choiceHint: "Global endpoint - api.minimax.io", + groupId: "minimax", + groupLabel: "MiniMax", + groupHint: "M2.5 (recommended)", + }, + }), + createProviderApiKeyAuthMethod({ + providerId: API_PROVIDER_ID, + methodId: "api-cn", + label: "MiniMax API key (CN)", + hint: "CN endpoint - api.minimaxi.com", + optionKey: "minimaxApiKey", + flagName: "--minimax-api-key", + envVar: "MINIMAX_API_KEY", + promptMessage: + "Enter MiniMax CN API key (sk-api- or sk-cp-)\nhttps://platform.minimaxi.com/user-center/basic-information/interface-key", + profileId: "minimax:cn", + defaultModel: modelRef(DEFAULT_MODEL), + expectedProviders: ["minimax", "minimax-cn"], + applyConfig: (cfg) => applyMinimaxApiConfigCn(cfg), + wizard: { + choiceId: "minimax-cn-api", + choiceLabel: "MiniMax API key (CN)", + choiceHint: "CN endpoint - api.minimaxi.com", + groupId: "minimax", + groupLabel: "MiniMax", + groupHint: "M2.5 (recommended)", + }, + }), + ], catalog: { order: "simple", run: async (ctx) => resolveApiCatalog(ctx), @@ -190,6 +242,14 @@ const minimaxPlugin = { label: "MiniMax OAuth (Global)", hint: "Global endpoint - api.minimax.io", kind: "device_code", + wizard: { + choiceId: "minimax-global-oauth", + choiceLabel: "MiniMax OAuth (Global)", + choiceHint: "Global endpoint - api.minimax.io", + groupId: "minimax", + groupLabel: "MiniMax", + groupHint: "M2.5 (recommended)", + }, run: createOAuthHandler("global"), }, { @@ -197,6 +257,14 @@ const minimaxPlugin = { label: "MiniMax OAuth (CN)", hint: "CN endpoint - api.minimaxi.com", kind: "device_code", + wizard: { + choiceId: "minimax-cn-oauth", + choiceLabel: "MiniMax OAuth (CN)", + choiceHint: "CN endpoint - api.minimaxi.com", + groupId: "minimax", + groupLabel: "MiniMax", + groupHint: "M2.5 (recommended)", + }, run: createOAuthHandler("cn"), }, ], diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index be406f26bbb..9155fb3cd30 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -4,6 +4,11 @@ import { } from "openclaw/plugin-sdk/core"; import { normalizeModelCompat } from "../../src/agents/model-compat.js"; import { normalizeProviderId } from "../../src/agents/model-selection.js"; +import { + applyOpenAIConfig, + OPENAI_DEFAULT_MODEL, +} from "../../src/commands/openai-model-default.js"; +import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import type { ProviderPlugin } from "../../src/plugins/types.js"; import { cloneFirstTemplateModel, @@ -89,7 +94,28 @@ export function buildOpenAIProvider(): ProviderPlugin { label: "OpenAI", docsPath: "/providers/models", envVars: ["OPENAI_API_KEY"], - auth: [], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "OpenAI API key", + hint: "Direct OpenAI API key", + optionKey: "openaiApiKey", + flagName: "--openai-api-key", + envVar: "OPENAI_API_KEY", + promptMessage: "Enter OpenAI API key", + defaultModel: OPENAI_DEFAULT_MODEL, + expectedProviders: ["openai"], + applyConfig: (cfg) => applyOpenAIConfig(cfg), + wizard: { + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + groupId: "openai", + groupLabel: "OpenAI", + groupHint: "Codex OAuth + API key", + }, + }), + ], resolveDynamicModel: (ctx) => resolveOpenAIGpt54ForwardCompatModel(ctx), normalizeResolvedModel: (ctx) => { if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) { diff --git a/src/commands/auth-choice.apply.plugin-provider.ts b/src/commands/auth-choice.apply.plugin-provider.ts index 139dd4500f4..5f4893b249c 100644 --- a/src/commands/auth-choice.apply.plugin-provider.ts +++ b/src/commands/auth-choice.apply.plugin-provider.ts @@ -44,6 +44,7 @@ export async function runProviderPluginAuthMethod(params: { emitNotes?: boolean; secretInputMode?: OnboardOptions["secretInputMode"]; allowSecretRefPrompt?: boolean; + opts?: Partial; }): Promise<{ config: ApplyAuthChoiceParams["config"]; defaultModel?: string }> { const agentId = params.agentId ?? resolveDefaultAgentId(params.config); const defaultAgentId = resolveDefaultAgentId(params.config); @@ -64,6 +65,7 @@ export async function runProviderPluginAuthMethod(params: { workspaceDir, prompter: params.prompter, runtime: params.runtime, + opts: params.opts, secretInputMode: params.secretInputMode, allowSecretRefPrompt: params.allowSecretRefPrompt, isRemote, @@ -134,6 +136,7 @@ export async function applyAuthChoiceLoadedPluginProvider( workspaceDir, secretInputMode: params.opts?.secretInputMode, allowSecretRefPrompt: true, + opts: params.opts, }); let agentModelOverride: string | undefined; @@ -213,6 +216,7 @@ export async function applyAuthChoicePluginProvider( workspaceDir, secretInputMode: params.opts?.secretInputMode, allowSecretRefPrompt: true, + opts: params.opts, }); nextConfig = applied.config; diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index 014984cd6f3..2973667830b 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -74,7 +74,7 @@ function resolveApiKeySecretInput( return normalized; } -function buildApiKeyCredential( +export function buildApiKeyCredential( provider: string, input: SecretInput, metadata?: Record, diff --git a/src/commands/onboard-non-interactive/local/auth-choice.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.test.ts index 9fe7a34cda9..b3255e7b4bb 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.test.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.test.ts @@ -32,11 +32,11 @@ function createRuntime() { } describe("applyNonInteractiveAuthChoice", () => { - it("resolves builtin API key auth before plugin provider resolution", async () => { + it("resolves plugin provider auth before builtin API key fallbacks", async () => { const runtime = createRuntime(); const nextConfig = { agents: { defaults: {} } } as OpenClawConfig; const resolvedConfig = { auth: { profiles: { "openai:default": { mode: "api_key" } } } }; - applySimpleNonInteractiveApiKeyChoice.mockResolvedValueOnce(resolvedConfig as never); + applyNonInteractivePluginProviderChoice.mockResolvedValueOnce(resolvedConfig as never); const result = await applyNonInteractiveAuthChoice({ nextConfig, @@ -47,7 +47,7 @@ describe("applyNonInteractiveAuthChoice", () => { }); expect(result).toBe(resolvedConfig); - expect(applySimpleNonInteractiveApiKeyChoice).toHaveBeenCalledOnce(); - expect(applyNonInteractivePluginProviderChoice).not.toHaveBeenCalled(); + expect(applyNonInteractivePluginProviderChoice).toHaveBeenCalledOnce(); + expect(applySimpleNonInteractiveApiKeyChoice).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 64a3379ad15..5c61e247c89 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -156,6 +156,24 @@ 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; + } + const simpleApiKeyChoice = await applySimpleNonInteractiveApiKeyChoice({ authChoice, nextConfig, @@ -406,24 +424,6 @@ 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" || diff --git a/src/plugins/provider-api-key-auth.ts b/src/plugins/provider-api-key-auth.ts new file mode 100644 index 00000000000..0ef8b356ea0 --- /dev/null +++ b/src/plugins/provider-api-key-auth.ts @@ -0,0 +1,152 @@ +import { upsertAuthProfile } from "../agents/auth-profiles.js"; +import { normalizeApiKeyInput, validateApiKeyInput } from "../commands/auth-choice.api-key.js"; +import { ensureApiKeyFromOptionEnvOrPrompt } from "../commands/auth-choice.apply-helpers.js"; +import { buildApiKeyCredential } from "../commands/onboard-auth.credentials.js"; +import { applyAuthProfileConfig } from "../commands/onboard-auth.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { SecretInput } from "../config/types.secrets.js"; +import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; +import type { + ProviderAuthMethod, + ProviderAuthMethodNonInteractiveContext, + ProviderPluginWizardSetup, +} from "./types.js"; + +type ProviderApiKeyAuthMethodOptions = { + providerId: string; + methodId: string; + label: string; + hint?: string; + wizard?: ProviderPluginWizardSetup; + optionKey: string; + flagName: `--${string}`; + envVar: string; + promptMessage: string; + profileId?: string; + defaultModel?: string; + expectedProviders?: string[]; + metadata?: Record; + noteMessage?: string; + noteTitle?: string; + applyConfig?: (cfg: OpenClawConfig) => OpenClawConfig; +}; + +function resolveStringOption(opts: Record | undefined, optionKey: string) { + return normalizeOptionalSecretInput(opts?.[optionKey]); +} + +function resolveProfileId(params: { providerId: string; profileId?: string }) { + return params.profileId?.trim() || `${params.providerId}:default`; +} + +function applyApiKeyConfig(params: { + ctx: ProviderAuthMethodNonInteractiveContext; + providerId: string; + profileId: string; + applyConfig?: (cfg: OpenClawConfig) => OpenClawConfig; +}) { + const next = applyAuthProfileConfig(params.ctx.config, { + profileId: params.profileId, + provider: params.providerId, + mode: "api_key", + }); + return params.applyConfig ? params.applyConfig(next) : next; +} + +export function createProviderApiKeyAuthMethod( + params: ProviderApiKeyAuthMethodOptions, +): ProviderAuthMethod { + return { + id: params.methodId, + label: params.label, + hint: params.hint, + kind: "api_key", + wizard: params.wizard, + run: async (ctx) => { + const opts = ctx.opts as Record | undefined; + const flagValue = resolveStringOption(opts, params.optionKey); + let capturedSecretInput: SecretInput | undefined; + let capturedMode: "plaintext" | "ref" | undefined; + + await ensureApiKeyFromOptionEnvOrPrompt({ + token: flagValue ?? normalizeOptionalSecretInput(ctx.opts?.token), + tokenProvider: flagValue + ? params.providerId + : normalizeOptionalSecretInput(ctx.opts?.tokenProvider), + secretInputMode: + ctx.allowSecretRefPrompt === false + ? (ctx.secretInputMode ?? "plaintext") + : ctx.secretInputMode, + config: ctx.config, + expectedProviders: params.expectedProviders ?? [params.providerId], + provider: params.providerId, + envLabel: params.envVar, + promptMessage: params.promptMessage, + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: ctx.prompter, + noteMessage: params.noteMessage, + noteTitle: params.noteTitle, + setCredential: async (apiKey, mode) => { + capturedSecretInput = apiKey; + capturedMode = mode; + }, + }); + + if (!capturedSecretInput) { + throw new Error(`Missing API key input for provider "${params.providerId}".`); + } + + return { + profiles: [ + { + profileId: resolveProfileId(params), + credential: buildApiKeyCredential( + params.providerId, + capturedSecretInput, + params.metadata, + capturedMode ? { secretInputMode: capturedMode } : undefined, + ), + }, + ], + ...(params.defaultModel ? { defaultModel: params.defaultModel } : {}), + }; + }, + runNonInteractive: async (ctx) => { + const opts = ctx.opts as Record | undefined; + const resolved = await ctx.resolveApiKey({ + provider: params.providerId, + flagValue: resolveStringOption(opts, params.optionKey), + flagName: params.flagName, + envVar: params.envVar, + }); + if (!resolved) { + return null; + } + + const profileId = resolveProfileId(params); + if (resolved.source !== "profile") { + const credential = ctx.toApiKeyCredential({ + provider: params.providerId, + resolved, + ...(params.metadata ? { metadata: params.metadata } : {}), + }); + if (!credential) { + return null; + } + upsertAuthProfile({ + profileId, + credential, + agentDir: ctx.agentDir, + }); + } + + return applyApiKeyConfig({ + ctx, + providerId: params.providerId, + profileId, + applyConfig: params.applyConfig, + }); + }, + }; +} diff --git a/src/plugins/provider-wizard.test.ts b/src/plugins/provider-wizard.test.ts index f55d9292824..eff361ee1c9 100644 --- a/src/plugins/provider-wizard.test.ts +++ b/src/plugins/provider-wizard.test.ts @@ -64,6 +64,46 @@ describe("provider wizard boundaries", () => { }); }); + it("builds wizard options from method-level metadata", () => { + const provider = makeProvider({ + id: "openai", + label: "OpenAI", + auth: [ + { + id: "api-key", + label: "OpenAI API key", + kind: "api_key", + wizard: { + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + groupId: "openai", + groupLabel: "OpenAI", + }, + run: vi.fn(), + }, + ], + }); + resolvePluginProviders.mockReturnValue([provider]); + + expect(resolveProviderWizardOptions({})).toEqual([ + { + value: "openai-api-key", + label: "OpenAI API key", + groupId: "openai", + groupLabel: "OpenAI", + }, + ]); + expect( + resolveProviderPluginChoice({ + providers: [provider], + choice: "openai-api-key", + }), + ).toEqual({ + provider, + method: provider.auth[0], + }); + }); + it("builds model-picker entries from plugin metadata and provider-method choices", () => { const provider = makeProvider({ id: "sglang", diff --git a/src/plugins/provider-wizard.ts b/src/plugins/provider-wizard.ts index dcac7e36d40..cbe90178056 100644 --- a/src/plugins/provider-wizard.ts +++ b/src/plugins/provider-wizard.ts @@ -61,6 +61,17 @@ function resolveMethodById( return provider.auth.find((method) => method.id.trim().toLowerCase() === normalizedMethodId); } +function listMethodWizardSetups(provider: ProviderPlugin): Array<{ + method: ProviderAuthMethod; + wizard: ProviderPluginWizardSetup; +}> { + return provider.auth + .map((method) => (method.wizard ? { method, wizard: method.wizard } : null)) + .filter((entry): entry is { method: ProviderAuthMethod; wizard: ProviderPluginWizardSetup } => + Boolean(entry), + ); +} + function buildSetupOptionForMethod(params: { provider: ProviderPlugin; wizard: ProviderPluginWizardSetup; @@ -93,6 +104,20 @@ export function resolveProviderWizardOptions(params: { const options: ProviderWizardOption[] = []; for (const provider of providers) { + const methodSetups = listMethodWizardSetups(provider); + for (const { method, wizard } of methodSetups) { + options.push( + buildSetupOptionForMethod({ + provider, + wizard, + method, + value: wizard.choiceId?.trim() || buildProviderPluginMethodChoice(provider.id, method.id), + }), + ); + } + if (methodSetups.length > 0) { + continue; + } const setup = provider.wizard?.setup; if (!setup) { continue; @@ -187,6 +212,13 @@ export function resolveProviderPluginChoice(params: { } for (const provider of params.providers) { + for (const { method, wizard } of listMethodWizardSetups(provider)) { + const choiceId = + wizard.choiceId?.trim() || buildProviderPluginMethodChoice(provider.id, method.id); + if (normalizeChoiceId(choiceId) === choice) { + return { provider, method }; + } + } const setup = provider.wizard?.setup; if (setup) { const setupChoiceId = resolveWizardSetupChoiceId(provider, setup); diff --git a/src/plugins/types.ts b/src/plugins/types.ts index bea63007fb2..f533b1b80a1 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -119,6 +119,15 @@ export type ProviderAuthContext = { workspaceDir?: string; prompter: WizardPrompter; runtime: RuntimeEnv; + /** + * Optional onboarding CLI options that triggered this auth flow. + * + * Present for setup/configure/auth-choice flows so provider methods can + * honor preseeded flags like `--openai-api-key` or generic + * `--token/--token-provider` pairs. Direct `models auth login` usually + * leaves this undefined. + */ + opts?: Partial; /** * Onboarding secret persistence preference. * @@ -187,6 +196,14 @@ export type ProviderAuthMethod = { label: string; hint?: string; kind: ProviderAuthKind; + /** + * Optional wizard/onboarding metadata for this specific auth method. + * + * Use this when one provider exposes multiple setup entries (for example API + * key + OAuth, or region-specific login flows). OpenClaw uses this to expose + * method-specific auth choices while keeping the provider id stable. + */ + wizard?: ProviderPluginWizardSetup; run: (ctx: ProviderAuthContext) => Promise; runNonInteractive?: ( ctx: ProviderAuthMethodNonInteractiveContext, From 2acbea0da74a3ba722a033fab6085fd170177c86 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:50:33 +0000 Subject: [PATCH 03/23] docs: restore onboard as canonical setup command --- src/cli/program/register.onboard.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 982be1a75c3..914732e4079 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -1,5 +1,4 @@ import type { Command } from "commander"; -import { formatCliCommand } from "../../cli/command-format.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"; @@ -50,14 +49,11 @@ const AUTH_CHOICE_HELP = formatStaticAuthChoiceChoicesForCli({ export function registerOnboardCommand(program: Command) { const command = program .command("onboard") - .description('Legacy alias for "openclaw setup --wizard"') - .addHelpText("after", () => - [ - "", - `${theme.muted("Docs:")} ${formatDocsLink("/cli/setup", "docs.openclaw.ai/cli/setup")}`, - `${theme.muted("Prefer:")} ${formatCliCommand("openclaw setup --wizard")}`, - "", - ].join("\n"), + .description("Interactive wizard to set up the gateway, workspace, and skills") + .addHelpText( + "after", + () => + `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/onboard", "docs.openclaw.ai/cli/onboard")}\n`, ) .option("--workspace ", "Agent workspace directory (default: ~/.openclaw/workspace)") .option( From f9e185887fcedaad53c711e5fa13d9a995cb5b87 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:50:48 +0000 Subject: [PATCH 04/23] docs: restore onboard docs references --- docs/.i18n/glossary.zh-CN.json | 4 - docs/automation/hooks.md | 2 +- docs/channels/bluebubbles.md | 4 +- docs/channels/feishu.md | 2 +- docs/channels/nostr.md | 2 +- docs/channels/synology-chat.md | 4 +- docs/cli/index.md | 2 +- docs/cli/onboard.md | 161 +++++++++++++++++++++--- docs/cli/setup.md | 30 ++--- docs/concepts/agent-workspace.md | 2 +- docs/concepts/model-providers.md | 32 ++--- docs/concepts/models.md | 2 +- docs/concepts/oauth.md | 4 +- docs/gateway/authentication.md | 2 +- docs/gateway/configuration-reference.md | 12 +- docs/gateway/configuration.md | 4 +- docs/gateway/local-models.md | 2 +- docs/gateway/multiple-gateways.md | 2 +- docs/help/faq.md | 12 +- docs/index.md | 4 +- docs/install/exe-dev.md | 2 +- docs/install/index.md | 6 +- docs/install/installer.md | 2 +- docs/install/macos-vm.md | 2 +- docs/platforms/digitalocean.md | 2 +- docs/platforms/index.md | 2 +- docs/platforms/linux.md | 4 +- docs/platforms/raspberry-pi.md | 2 +- docs/platforms/windows.md | 10 +- docs/providers/anthropic.md | 6 +- docs/providers/cloudflare-ai-gateway.md | 4 +- docs/providers/glm.md | 8 +- docs/providers/huggingface.md | 4 +- docs/providers/index.md | 2 +- docs/providers/kilocode.md | 2 +- docs/providers/litellm.md | 2 +- docs/providers/minimax.md | 2 +- docs/providers/mistral.md | 4 +- docs/providers/models.md | 2 +- docs/providers/moonshot.md | 4 +- docs/providers/nvidia.md | 2 +- docs/providers/ollama.md | 8 +- docs/providers/openai.md | 6 +- docs/providers/opencode-go.md | 4 +- docs/providers/opencode.md | 8 +- docs/providers/openrouter.md | 2 +- docs/providers/qianfan.md | 2 +- docs/providers/sglang.md | 2 +- docs/providers/synthetic.md | 2 +- docs/providers/together.md | 4 +- docs/providers/venice.md | 4 +- docs/providers/vercel-ai-gateway.md | 4 +- docs/providers/xiaomi.md | 4 +- docs/providers/zai.md | 8 +- docs/reference/wizard.md | 14 +-- docs/start/getting-started.md | 2 +- docs/start/onboarding-overview.md | 20 +-- docs/start/wizard-cli-automation.md | 34 ++--- docs/start/wizard-cli-reference.md | 26 ++-- docs/start/wizard.md | 10 +- docs/tools/plugin.md | 8 +- src/cli/program/register.setup.ts | 6 +- src/commands/onboard.ts | 2 +- src/commands/reset.ts | 4 +- 64 files changed, 328 insertions(+), 219 deletions(-) diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index bc1892d1e9a..36e44b6d909 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -55,10 +55,6 @@ "source": "CLI Setup Reference", "target": "CLI 设置参考" }, - { - "source": "Setup Overview", - "target": "设置概览" - }, { "source": "Setup Wizard (CLI)", "target": "设置向导(CLI)" diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index 84c7a234e11..deda79d3db5 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -74,7 +74,7 @@ openclaw hooks info session-memory ### Onboarding -During onboarding (`openclaw setup --wizard`), you'll be prompted to enable recommended hooks. The wizard automatically discovers eligible hooks and presents them for selection. +During onboarding (`openclaw onboard`), you'll be prompted to enable recommended hooks. The wizard automatically discovers eligible hooks and presents them for selection. ## Hook Discovery diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md index c51c7967b00..9c2f0eb6de4 100644 --- a/docs/channels/bluebubbles.md +++ b/docs/channels/bluebubbles.md @@ -26,7 +26,7 @@ Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **R 1. Install the BlueBubbles server on your Mac (follow the instructions at [bluebubbles.app/install](https://bluebubbles.app/install)). 2. In the BlueBubbles config, enable the web API and set a password. -3. Run `openclaw setup --wizard` and select BlueBubbles, or configure manually: +3. Run `openclaw onboard` and select BlueBubbles, or configure manually: ```json5 { @@ -129,7 +129,7 @@ launchctl load ~/Library/LaunchAgents/com.user.poke-messages.plist BlueBubbles is available in the interactive setup wizard: ``` -openclaw setup --wizard +openclaw onboard ``` The wizard prompts for: diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 7e13a3077df..3768906d940 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -35,7 +35,7 @@ There are two ways to add the Feishu channel: If you just installed OpenClaw, run the setup wizard: ```bash -openclaw setup --wizard +openclaw onboard ``` The wizard guides you through: diff --git a/docs/channels/nostr.md b/docs/channels/nostr.md index ce410dd879a..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 setup wizard (`openclaw setup --wizard`) 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/synology-chat.md b/docs/channels/synology-chat.md index cc3b2f2ed73..aae655f27b7 100644 --- a/docs/channels/synology-chat.md +++ b/docs/channels/synology-chat.md @@ -27,7 +27,7 @@ Details: [Plugins](/tools/plugin) ## Quick setup 1. Install and enable the Synology Chat plugin. - - `openclaw setup --wizard` now shows Synology Chat in the same channel setup list as `openclaw channels add`. + - `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. @@ -36,7 +36,7 @@ Details: [Plugins](/tools/plugin) - `https://gateway-host/webhook/synology` by default. - Or your custom `channels.synology-chat.webhookPath`. 4. Finish setup in OpenClaw. - - Guided: `openclaw setup --wizard` + - Guided: `openclaw onboard` - Direct: `openclaw channels add --channel synology-chat --token --url ` 5. Restart gateway and send a DM to the Synology Chat bot. diff --git a/docs/cli/index.md b/docs/cli/index.md index 80e6efdadd5..ded970cde9d 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -13,7 +13,7 @@ This page describes the current CLI behavior. If commands change, update this do ## Command pages - [`setup`](/cli/setup) -- [`onboard`](/cli/onboard) (legacy alias for `setup --wizard`) +- [`onboard`](/cli/onboard) - [`configure`](/cli/configure) - [`config`](/cli/config) - [`completion`](/cli/completion) diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index 16aa8413135..899ccd82713 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -1,30 +1,157 @@ --- -summary: "Legacy CLI alias for `openclaw setup --wizard`" +summary: "CLI reference for `openclaw onboard` (interactive setup wizard)" read_when: - - You encountered `openclaw onboard` in older docs or scripts + - You want guided setup for gateway, workspace, auth, channels, and skills title: "onboard" --- # `openclaw onboard` -Legacy alias for `openclaw setup --wizard`. - -Prefer: - -```bash -openclaw setup --wizard -``` - -`openclaw onboard` still accepts the same flags and behavior for compatibility. +Interactive setup wizard (local or remote Gateway setup). ## Related guides -- Primary command docs: [`openclaw setup`](/cli/setup) -- Setup wizard guide: [Setup Wizard (CLI)](/start/wizard) -- Setup overview: [Setup Overview](/start/onboarding-overview) -- Setup wizard reference: [CLI Setup Reference](/start/wizard-cli-reference) +- CLI onboarding hub: [Setup Wizard (CLI)](/start/wizard) +- Onboarding overview: [Onboarding Overview](/start/onboarding-overview) +- CLI onboarding reference: [CLI Setup Reference](/start/wizard-cli-reference) - CLI automation: [CLI Automation](/start/wizard-cli-automation) - macOS onboarding: [Onboarding (macOS App)](/start/onboarding) -For examples, flags, and non-interactive behavior, use the primary docs at -[`openclaw setup`](/cli/setup) and [CLI Setup Reference](/start/wizard-cli-reference). +## Examples + +```bash +openclaw onboard +openclaw onboard --flow quickstart +openclaw onboard --flow manual +openclaw onboard --mode remote --remote-url wss://gateway-host:18789 +``` + +For plaintext private-network `ws://` targets (trusted networks only), set +`OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` in the onboarding process environment. + +Non-interactive custom provider: + +```bash +openclaw onboard --non-interactive \ + --auth-choice custom-api-key \ + --custom-base-url "https://llm.example.com/v1" \ + --custom-model-id "foo-large" \ + --custom-api-key "$CUSTOM_API_KEY" \ + --secret-input-mode plaintext \ + --custom-compatibility openai +``` + +`--custom-api-key` is optional in non-interactive mode. If omitted, onboarding checks `CUSTOM_API_KEY`. + +Non-interactive Ollama: + +```bash +openclaw onboard --non-interactive \ + --auth-choice ollama \ + --custom-base-url "http://ollama-host:11434" \ + --custom-model-id "qwen3.5:27b" \ + --accept-risk +``` + +`--custom-base-url` defaults to `http://127.0.0.1:11434`. `--custom-model-id` is optional; if omitted, onboarding uses Ollama's suggested defaults. Cloud model IDs such as `kimi-k2.5:cloud` also work here. + +Store provider keys as refs instead of plaintext: + +```bash +openclaw onboard --non-interactive \ + --auth-choice openai-api-key \ + --secret-input-mode ref \ + --accept-risk +``` + +With `--secret-input-mode ref`, onboarding writes env-backed refs instead of plaintext key values. +For auth-profile backed providers this writes `keyRef` entries; for custom providers this writes `models.providers..apiKey` as an env ref (for example `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`). + +Non-interactive `ref` mode contract: + +- Set the provider env var in the onboarding process environment (for example `OPENAI_API_KEY`). +- Do not pass inline key flags (for example `--openai-api-key`) unless that env var is also set. +- If an inline key flag is passed without the required env var, onboarding fails fast with guidance. + +Gateway token options in non-interactive mode: + +- `--gateway-auth token --gateway-token ` stores a plaintext token. +- `--gateway-auth token --gateway-token-ref-env ` stores `gateway.auth.token` as an env SecretRef. +- `--gateway-token` and `--gateway-token-ref-env` are mutually exclusive. +- `--gateway-token-ref-env` requires a non-empty env var in the onboarding process environment. +- With `--install-daemon`, when token auth requires a token, SecretRef-managed gateway tokens are validated but not persisted as resolved plaintext in supervisor service environment metadata. +- With `--install-daemon`, if token mode requires a token and the configured token SecretRef is unresolved, onboarding fails closed with remediation guidance. +- With `--install-daemon`, if both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, onboarding blocks install until mode is set explicitly. + +Example: + +```bash +export OPENCLAW_GATEWAY_TOKEN="your-token" +openclaw onboard --non-interactive \ + --mode local \ + --auth-choice skip \ + --gateway-auth token \ + --gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN \ + --accept-risk +``` + +Non-interactive local gateway health: + +- Unless you pass `--skip-health`, onboarding waits for a reachable local gateway before it exits successfully. +- `--install-daemon` starts the managed gateway install path first. Without it, you must already have a local gateway running, for example `openclaw gateway run`. +- If you only want config/workspace/bootstrap writes in automation, use `--skip-health`. +- On native Windows, `--install-daemon` tries Scheduled Tasks first and falls back to a per-user Startup-folder login item if task creation is denied. + +Interactive onboarding behavior with reference mode: + +- Choose **Use secret reference** when prompted. +- Then choose either: + - Environment variable + - Configured secret provider (`file` or `exec`) +- Onboarding performs a fast preflight validation before saving the ref. + - If validation fails, onboarding shows the error and lets you retry. + +Non-interactive Z.AI endpoint choices: + +Note: `--auth-choice zai-api-key` now auto-detects the best Z.AI endpoint for your key (prefers the general API with `zai/glm-5`). +If you specifically want the GLM Coding Plan endpoints, pick `zai-coding-global` or `zai-coding-cn`. + +```bash +# Promptless endpoint selection +openclaw onboard --non-interactive \ + --auth-choice zai-coding-global \ + --zai-api-key "$ZAI_API_KEY" + +# Other Z.AI endpoint choices: +# --auth-choice zai-coding-cn +# --auth-choice zai-global +# --auth-choice zai-cn +``` + +Non-interactive Mistral example: + +```bash +openclaw onboard --non-interactive \ + --auth-choice mistral-api-key \ + --mistral-api-key "$MISTRAL_API_KEY" +``` + +Flow notes: + +- `quickstart`: minimal prompts, auto-generates a gateway token. +- `manual`: full prompts for port/bind/auth (alias of `advanced`). +- Local onboarding DM scope behavior: [CLI Setup Reference](/start/wizard-cli-reference#outputs-and-internals). +- Fastest first chat: `openclaw dashboard` (Control UI, no channel setup). +- Custom Provider: connect any OpenAI or Anthropic compatible endpoint, + including hosted providers not listed. Use Unknown to auto-detect. + +## Common follow-up commands + +```bash +openclaw configure +openclaw agents add +``` + + +`--json` does not imply non-interactive mode. Use `--non-interactive` for scripts. + diff --git a/docs/cli/setup.md b/docs/cli/setup.md index d8b5f686ef9..d8992ba8a43 100644 --- a/docs/cli/setup.md +++ b/docs/cli/setup.md @@ -1,43 +1,29 @@ --- -summary: "CLI reference for `openclaw setup` (initialize config/workspace or run the setup wizard)" +summary: "CLI reference for `openclaw setup` (initialize config + workspace)" read_when: - - You want first-run setup without the guided wizard - - You want the guided setup wizard via `openclaw setup --wizard` + - You’re doing first-run setup without the full setup wizard - You want to set the default workspace path title: "setup" --- # `openclaw setup` -Initialize `~/.openclaw/openclaw.json` and the agent workspace, or run the guided setup wizard. +Initialize `~/.openclaw/openclaw.json` and the agent workspace. Related: - Getting started: [Getting started](/start/getting-started) -- Setup wizard: [Setup Wizard (CLI)](/start/wizard) -- macOS app onboarding: [Onboarding](/start/onboarding) +- Wizard: [Onboarding](/start/onboarding) ## Examples ```bash openclaw setup openclaw setup --workspace ~/.openclaw/workspace -openclaw setup --wizard -openclaw setup --wizard --install-daemon ``` -Without flags, `openclaw setup` only ensures config + workspace defaults. -Use `--wizard` for the full guided flow. +To run the wizard via setup: -## Modes - -- `openclaw setup`: initialize config/workspace defaults only -- `openclaw setup --wizard`: guided setup for auth, gateway, channels, and skills -- `openclaw setup --wizard --non-interactive`: scripted setup flow - -## Related guides - -- Setup wizard guide: [Setup Wizard (CLI)](/start/wizard) -- Setup wizard reference: [CLI Setup Reference](/start/wizard-cli-reference) -- Setup wizard automation: [CLI Automation](/start/wizard-cli-automation) -- Legacy alias: [`openclaw onboard`](/cli/onboard) +```bash +openclaw setup --wizard +``` diff --git a/docs/concepts/agent-workspace.md b/docs/concepts/agent-workspace.md index 7fc114818cb..ff55f241bcd 100644 --- a/docs/concepts/agent-workspace.md +++ b/docs/concepts/agent-workspace.md @@ -36,7 +36,7 @@ inside a sandbox workspace under `~/.openclaw/sandboxes`, not your host workspac } ``` -`openclaw setup --wizard`, `openclaw configure`, or `openclaw setup` will create the +`openclaw onboard`, `openclaw configure`, or `openclaw setup` will create the workspace and seed the bootstrap files if they are missing. Sandbox seed copies only accept regular in-workspace files; symlink/hardlink aliases that resolve outside the source workspace are ignored. diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 8e8f17f4a67..fc0656c0dd4 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -15,7 +15,7 @@ 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 setup --wizard`, `openclaw models list`, `openclaw models set `. +- 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`. @@ -139,7 +139,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Auth: `OPENAI_API_KEY` - Optional rotation: `OPENAI_API_KEYS`, `OPENAI_API_KEY_1`, `OPENAI_API_KEY_2`, plus `OPENCLAW_LIVE_OPENAI_KEY` (single override) - Example models: `openai/gpt-5.4`, `openai/gpt-5.4-pro` -- CLI: `openclaw setup --wizard --auth-choice openai-api-key` +- CLI: `openclaw onboard --auth-choice openai-api-key` - Default transport is `auto` (WebSocket-first, SSE fallback) - Override per model via `agents.defaults.models["openai/"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`) - OpenAI Responses WebSocket warm-up defaults to enabled via `params.openaiWsWarmup` (`true`/`false`) @@ -159,7 +159,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Auth: `ANTHROPIC_API_KEY` or `claude setup-token` - Optional rotation: `ANTHROPIC_API_KEYS`, `ANTHROPIC_API_KEY_1`, `ANTHROPIC_API_KEY_2`, plus `OPENCLAW_LIVE_ANTHROPIC_KEY` (single override) - Example model: `anthropic/claude-opus-4-6` -- CLI: `openclaw setup --wizard --auth-choice token` (paste setup-token) or `openclaw models auth paste-token --provider anthropic` +- CLI: `openclaw onboard --auth-choice token` (paste setup-token) or `openclaw models auth paste-token --provider anthropic` - Direct API-key models support the shared `/fast` toggle and `params.fastMode`; OpenClaw maps that to Anthropic `service_tier` (`auto` vs `standard_only`) - Policy note: setup-token support is technical compatibility; Anthropic has blocked some subscription usage outside Claude Code in the past. Verify current Anthropic terms and decide based on your risk tolerance. - Recommendation: Anthropic API key auth is the safer, recommended path over subscription setup-token auth. @@ -175,7 +175,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Provider: `openai-codex` - Auth: OAuth (ChatGPT) - Example model: `openai-codex/gpt-5.4` -- CLI: `openclaw setup --wizard --auth-choice openai-codex` or `openclaw models auth login --provider openai-codex` +- CLI: `openclaw onboard --auth-choice openai-codex` or `openclaw models auth login --provider openai-codex` - Default transport is `auto` (WebSocket-first, SSE fallback) - Override per model via `agents.defaults.models["openai-codex/"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`) - Shares the same `/fast` toggle and `params.fastMode` config as direct `openai/*` @@ -194,7 +194,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Zen runtime provider: `opencode` - Go runtime provider: `opencode-go` - Example models: `opencode/claude-opus-4-6`, `opencode-go/kimi-k2.5` -- CLI: `openclaw setup --wizard --auth-choice opencode-zen` or `openclaw setup --wizard --auth-choice opencode-go` +- CLI: `openclaw onboard --auth-choice opencode-zen` or `openclaw onboard --auth-choice opencode-go` ```json5 { @@ -209,7 +209,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Optional rotation: `GEMINI_API_KEYS`, `GEMINI_API_KEY_1`, `GEMINI_API_KEY_2`, `GOOGLE_API_KEY` fallback, and `OPENCLAW_LIVE_GEMINI_KEY` (single override) - Example models: `google/gemini-3.1-pro-preview`, `google/gemini-3-flash-preview` - Compatibility: legacy OpenClaw config using `google/gemini-3.1-flash-preview` is normalized to `google/gemini-3-flash-preview` -- CLI: `openclaw setup --wizard --auth-choice gemini-api-key` +- CLI: `openclaw onboard --auth-choice gemini-api-key` ### Google Vertex and Gemini CLI @@ -227,7 +227,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Provider: `zai` - Auth: `ZAI_API_KEY` - Example model: `zai/glm-5` -- CLI: `openclaw setup --wizard --auth-choice zai-api-key` +- CLI: `openclaw onboard --auth-choice zai-api-key` - Aliases: `z.ai/*` and `z-ai/*` normalize to `zai/*` ### Vercel AI Gateway @@ -235,14 +235,14 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Provider: `vercel-ai-gateway` - Auth: `AI_GATEWAY_API_KEY` - Example model: `vercel-ai-gateway/anthropic/claude-opus-4.6` -- CLI: `openclaw setup --wizard --auth-choice ai-gateway-api-key` +- CLI: `openclaw onboard --auth-choice ai-gateway-api-key` ### Kilo Gateway - Provider: `kilocode` - Auth: `KILOCODE_API_KEY` - Example model: `kilocode/anthropic/claude-opus-4.6` -- CLI: `openclaw setup --wizard --kilocode-api-key ` +- CLI: `openclaw onboard --kilocode-api-key ` - Base URL: `https://api.kilo.ai/api/gateway/` - Expanded built-in catalog includes GLM-5 Free, MiniMax M2.5 Free, GPT-5.2, Gemini 3 Pro Preview, Gemini 3 Flash Preview, Grok Code Fast 1, and Kimi K2.5. @@ -271,13 +271,13 @@ See [/providers/kilocode](/providers/kilocode) for setup details. - xAI: `xai` (`XAI_API_KEY`) - Mistral: `mistral` (`MISTRAL_API_KEY`) - Example model: `mistral/mistral-large-latest` -- CLI: `openclaw setup --wizard --auth-choice mistral-api-key` +- CLI: `openclaw onboard --auth-choice mistral-api-key` - Groq: `groq` (`GROQ_API_KEY`) - Cerebras: `cerebras` (`CEREBRAS_API_KEY`) - 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 example model: `huggingface/deepseek-ai/DeepSeek-R1`; CLI: `openclaw setup --wizard --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) @@ -367,7 +367,7 @@ Volcano Engine (火山引擎) provides access to Doubao and other models in Chin - Provider: `volcengine` (coding: `volcengine-plan`) - Auth: `VOLCANO_ENGINE_API_KEY` - Example model: `volcengine/doubao-seed-1-8-251228` -- CLI: `openclaw setup --wizard --auth-choice volcengine-api-key` +- CLI: `openclaw onboard --auth-choice volcengine-api-key` ```json5 { @@ -400,7 +400,7 @@ BytePlus ARK provides access to the same models as Volcano Engine for internatio - Provider: `byteplus` (coding: `byteplus-plan`) - Auth: `BYTEPLUS_API_KEY` - Example model: `byteplus/seed-1-8-251228` -- CLI: `openclaw setup --wizard --auth-choice byteplus-api-key` +- CLI: `openclaw onboard --auth-choice byteplus-api-key` ```json5 { @@ -431,7 +431,7 @@ Synthetic provides Anthropic-compatible models behind the `synthetic` provider: - Provider: `synthetic` - Auth: `SYNTHETIC_API_KEY` - Example model: `synthetic/hf:MiniMaxAI/MiniMax-M2.5` -- CLI: `openclaw setup --wizard --auth-choice synthetic-api-key` +- CLI: `openclaw onboard --auth-choice synthetic-api-key` ```json5 { @@ -485,7 +485,7 @@ ollama pull llama3.3 Ollama is detected locally at `http://127.0.0.1:11434` when you opt in with `OLLAMA_API_KEY`, and the bundled provider plugin adds Ollama directly to -`openclaw setup --wizard` and the model picker. See [/providers/ollama](/providers/ollama) +`openclaw onboard` and the model picker. See [/providers/ollama](/providers/ollama) for onboarding, cloud/local mode, and custom configuration. ### vLLM @@ -595,7 +595,7 @@ Notes: ## CLI examples ```bash -openclaw setup --wizard --auth-choice opencode-zen +openclaw onboard --auth-choice opencode-zen openclaw models set opencode/claude-opus-4-6 openclaw models list ``` diff --git a/docs/concepts/models.md b/docs/concepts/models.md index f190630ac36..e85e605456f 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -39,7 +39,7 @@ Related: If you don’t want to hand-edit config, run the setup wizard: ```bash -openclaw setup --wizard +openclaw onboard ``` It can set up model + auth for common providers, including **OpenAI Code (Codex) diff --git a/docs/concepts/oauth.md b/docs/concepts/oauth.md index 4b8b2739c22..4766687ad51 100644 --- a/docs/concepts/oauth.md +++ b/docs/concepts/oauth.md @@ -92,7 +92,7 @@ Flow shape: 2. paste the token into OpenClaw 3. store as a token auth profile (no refresh) -The wizard path is `openclaw setup --wizard` → auth choice `setup-token` (Anthropic). +The wizard path is `openclaw onboard` → auth choice `setup-token` (Anthropic). ### OpenAI Codex (ChatGPT OAuth) @@ -107,7 +107,7 @@ Flow shape (PKCE): 5. exchange at `https://auth.openai.com/oauth/token` 6. extract `accountId` from the access token and store `{ access, refresh, expires, accountId }` -Wizard path is `openclaw setup --wizard` → auth choice `openai-codex`. +Wizard path is `openclaw onboard` → auth choice `openai-codex`. ## Refresh + expiry diff --git a/docs/gateway/authentication.md b/docs/gateway/authentication.md index fe8e5b760d3..c25501e6cdd 100644 --- a/docs/gateway/authentication.md +++ b/docs/gateway/authentication.md @@ -50,7 +50,7 @@ openclaw doctor ``` If you’d rather not manage env vars yourself, the setup wizard can store -API keys for daemon use: `openclaw setup --wizard`. +API keys for daemon use: `openclaw onboard`. See [Help](/help) for details on env inheritance (`env.shellEnv`, `~/.openclaw/.env`, systemd/launchd). diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index b28cde9c260..0653fd3834f 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2182,7 +2182,7 @@ Use `cerebras/zai-glm-4.7` for Cerebras; `zai/glm-4.7` for Z.AI direct. } ``` -Set `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`). Use `opencode/...` refs for the Zen catalog or `opencode-go/...` refs for the Go catalog. Shortcut: `openclaw setup --wizard --auth-choice opencode-zen` or `openclaw setup --wizard --auth-choice opencode-go`. +Set `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`). Use `opencode/...` refs for the Zen catalog or `opencode-go/...` refs for the Go catalog. Shortcut: `openclaw onboard --auth-choice opencode-zen` or `openclaw onboard --auth-choice opencode-go`. @@ -2199,7 +2199,7 @@ Set `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`). Use `opencode/...` refs for } ``` -Set `ZAI_API_KEY`. `z.ai/*` and `z-ai/*` are accepted aliases. Shortcut: `openclaw setup --wizard --auth-choice zai-api-key`. +Set `ZAI_API_KEY`. `z.ai/*` and `z-ai/*` are accepted aliases. Shortcut: `openclaw onboard --auth-choice zai-api-key`. - General endpoint: `https://api.z.ai/api/paas/v4` - Coding endpoint (default): `https://api.z.ai/api/coding/paas/v4` @@ -2242,7 +2242,7 @@ Set `ZAI_API_KEY`. `z.ai/*` and `z-ai/*` are accepted aliases. Shortcut: `opencl } ``` -For the China endpoint: `baseUrl: "https://api.moonshot.cn/v1"` or `openclaw setup --wizard --auth-choice moonshot-api-key-cn`. +For the China endpoint: `baseUrl: "https://api.moonshot.cn/v1"` or `openclaw onboard --auth-choice moonshot-api-key-cn`. @@ -2260,7 +2260,7 @@ For the China endpoint: `baseUrl: "https://api.moonshot.cn/v1"` or `openclaw set } ``` -Anthropic-compatible, built-in provider. Shortcut: `openclaw setup --wizard --auth-choice kimi-code-api-key`. +Anthropic-compatible, built-in provider. Shortcut: `openclaw onboard --auth-choice kimi-code-api-key`. @@ -2299,7 +2299,7 @@ Anthropic-compatible, built-in provider. Shortcut: `openclaw setup --wizard --au } ``` -Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw setup --wizard --auth-choice synthetic-api-key`. +Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw onboard --auth-choice synthetic-api-key`. @@ -2339,7 +2339,7 @@ Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw se } ``` -Set `MINIMAX_API_KEY`. Shortcut: `openclaw setup --wizard --auth-choice minimax-api`. +Set `MINIMAX_API_KEY`. Shortcut: `openclaw onboard --auth-choice minimax-api`. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index db4bb167417..a699e74652f 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -20,7 +20,7 @@ If the file is missing, OpenClaw uses safe defaults. Common reasons to add a con See the [full reference](/gateway/configuration-reference) for every available field. -**New to configuration?** Start with `openclaw setup --wizard` for interactive setup, or check out the [Configuration Examples](/gateway/configuration-examples) guide for complete copy-paste configs. +**New to configuration?** Start with `openclaw onboard` for interactive setup, or check out the [Configuration Examples](/gateway/configuration-examples) guide for complete copy-paste configs. ## Minimal config @@ -38,7 +38,7 @@ See the [full reference](/gateway/configuration-reference) for every available f ```bash - openclaw setup --wizard # full setup wizard + openclaw onboard # full setup wizard openclaw configure # config wizard ``` diff --git a/docs/gateway/local-models.md b/docs/gateway/local-models.md index 93a63c38170..4059f988776 100644 --- a/docs/gateway/local-models.md +++ b/docs/gateway/local-models.md @@ -11,7 +11,7 @@ title: "Local Models" Local is doable, but OpenClaw expects large context + strong defenses against prompt injection. Small cards truncate context and leak safety. Aim high: **≥2 maxed-out Mac Studios or equivalent GPU rig (~$30k+)**. A single **24 GB** GPU works only for lighter prompts with higher latency. Use the **largest / full-size model variant you can run**; aggressively quantized or “small” checkpoints raise prompt-injection risk (see [Security](/gateway/security)). -If you want the lowest-friction local setup, start with [Ollama](/providers/ollama) and `openclaw setup --wizard`. This page is the opinionated guide for higher-end local stacks and custom OpenAI-compatible local servers. +If you want the lowest-friction local setup, start with [Ollama](/providers/ollama) and `openclaw onboard`. This page is the opinionated guide for higher-end local stacks and custom OpenAI-compatible local servers. ## Recommended: LM Studio + MiniMax M2.5 (Responses API, full-size) diff --git a/docs/gateway/multiple-gateways.md b/docs/gateway/multiple-gateways.md index b2d5257f0ff..6d1cf423b98 100644 --- a/docs/gateway/multiple-gateways.md +++ b/docs/gateway/multiple-gateways.md @@ -59,7 +59,7 @@ Port spacing: leave at least 20 ports between base ports so the derived browser/ ```bash # Main bot (existing or fresh, without --profile param) # Runs on port 18789 + Chrome CDC/Canvas/... Ports -openclaw setup --wizard +openclaw onboard openclaw gateway install # Rescue bot (isolated profile + ports) diff --git a/docs/help/faq.md b/docs/help/faq.md index 670ea170c19..8fdf39ab5c1 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -321,7 +321,7 @@ The repo recommends running from source and using the setup wizard: ```bash curl -fsSL https://openclaw.ai/install.sh | bash -openclaw setup --wizard --install-daemon +openclaw onboard --install-daemon ``` The wizard can also build UI assets automatically. After onboarding, you typically run the Gateway on port **18789**. @@ -334,10 +334,10 @@ cd openclaw pnpm install pnpm build pnpm ui:build # auto-installs UI deps on first run -openclaw setup --wizard +openclaw onboard ``` -If you don't have a global install yet, run it via `pnpm openclaw setup --wizard`. +If you don't have a global install yet, run it via `pnpm openclaw onboard`. ### How do I open the dashboard after onboarding @@ -687,7 +687,7 @@ Docs: [Update](/cli/update), [Updating](/install/updating). ### What does the setup wizard actually do -`openclaw setup --wizard` is the recommended setup path. In **local mode** it walks you through: +`openclaw onboard` is the recommended setup path. In **local mode** it walks you through: - **Model/auth setup** (provider OAuth/setup-token flows and API keys supported, plus local model options such as LM Studio) - **Workspace** location + bootstrap files @@ -1904,7 +1904,7 @@ openclaw reset --scope full --yes --non-interactive Then re-run setup: ```bash -openclaw setup --wizard --install-daemon +openclaw onboard --install-daemon ``` Notes: @@ -2092,7 +2092,7 @@ Quickest setup: 1. Install Ollama from `https://ollama.com/download` 2. Pull a local model such as `ollama pull glm-4.7-flash` 3. If you want Ollama Cloud too, run `ollama signin` -4. Run `openclaw setup --wizard` and choose `Ollama` +4. Run `openclaw onboard` and choose `Ollama` 5. Pick `Local` or `Cloud + Local` Notes: diff --git a/docs/index.md b/docs/index.md index e8c2210caff..7c69600f55d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,7 +34,7 @@ title: "OpenClaw" Install OpenClaw and bring up the Gateway in minutes. - Guided setup with `openclaw setup --wizard` and pairing flows. + Guided setup with `openclaw onboard` and pairing flows. Launch the browser dashboard for chat, config, and sessions. @@ -103,7 +103,7 @@ The Gateway is the single source of truth for sessions, routing, and channel con ```bash - openclaw setup --wizard --install-daemon + openclaw onboard --install-daemon ``` diff --git a/docs/install/exe-dev.md b/docs/install/exe-dev.md index b66865593da..c49dab4e426 100644 --- a/docs/install/exe-dev.md +++ b/docs/install/exe-dev.md @@ -31,7 +31,7 @@ Shelley, [exe.dev](https://exe.dev)'s agent, can install OpenClaw instantly with prompt. The prompt used is as below: ``` -Set up OpenClaw (https://docs.openclaw.ai/install) on this VM. Use the non-interactive and accept-risk flags for openclaw setup --wizarding. Add the supplied auth or token as needed. Configure nginx to forward from the default port 18789 to the root location on the default enabled site config, making sure to enable Websocket support. Pairing is done by "openclaw devices list" and "openclaw devices approve ". Make sure the dashboard shows that OpenClaw's health is OK. exe.dev handles forwarding from port 8000 to port 80/443 and HTTPS for us, so the final "reachable" should be .exe.xyz, without port specification. +Set up OpenClaw (https://docs.openclaw.ai/install) on this VM. Use the non-interactive and accept-risk flags for openclaw onboarding. Add the supplied auth or token as needed. Configure nginx to forward from the default port 18789 to the root location on the default enabled site config, making sure to enable Websocket support. Pairing is done by "openclaw devices list" and "openclaw devices approve ". Make sure the dashboard shows that OpenClaw's health is OK. exe.dev handles forwarding from port 8000 to port 80/443 and HTTPS for us, so the final "reachable" should be .exe.xyz, without port specification. ``` ## Manual installation diff --git a/docs/install/index.md b/docs/install/index.md index 59396c49b5f..21adfdaa592 100644 --- a/docs/install/index.md +++ b/docs/install/index.md @@ -76,7 +76,7 @@ For VPS/cloud hosts, avoid third-party "1-click" marketplace images when possibl ```bash npm install -g openclaw@latest - openclaw setup --wizard --install-daemon + openclaw onboard --install-daemon ``` @@ -93,7 +93,7 @@ For VPS/cloud hosts, avoid third-party "1-click" marketplace images when possibl ```bash pnpm add -g openclaw@latest pnpm approve-builds -g # approve openclaw, node-llama-cpp, sharp, etc. - openclaw setup --wizard --install-daemon + openclaw onboard --install-daemon ``` @@ -140,7 +140,7 @@ For VPS/cloud hosts, avoid third-party "1-click" marketplace images when possibl ```bash - openclaw setup --wizard --install-daemon + openclaw onboard --install-daemon ``` diff --git a/docs/install/installer.md b/docs/install/installer.md index 813fa7b31b4..5859c22fd0d 100644 --- a/docs/install/installer.md +++ b/docs/install/installer.md @@ -224,7 +224,7 @@ Designed for environments where you want everything under a local prefix (defaul | `--version ` | OpenClaw version or dist-tag (default: `latest`) | | `--node-version ` | Node version (default: `22.22.0`) | | `--json` | Emit NDJSON events | -| `--onboard` | Run `openclaw setup --wizard` after install | +| `--onboard` | Run `openclaw onboard` after install | | `--no-onboard` | Skip onboarding (default) | | `--set-npm-prefix` | On Linux, force npm prefix to `~/.npm-global` if current prefix is not writable | | `--help` | Show usage (`-h`) | diff --git a/docs/install/macos-vm.md b/docs/install/macos-vm.md index 3e036c6ee0d..f2eadfda113 100644 --- a/docs/install/macos-vm.md +++ b/docs/install/macos-vm.md @@ -138,7 +138,7 @@ Inside the VM: ```bash npm install -g openclaw@latest -openclaw setup --wizard --install-daemon +openclaw onboard --install-daemon ``` Follow the onboarding prompts to set up your model provider (Anthropic, OpenAI, etc.). diff --git a/docs/platforms/digitalocean.md b/docs/platforms/digitalocean.md index aaea2644ca6..cd05587ae76 100644 --- a/docs/platforms/digitalocean.md +++ b/docs/platforms/digitalocean.md @@ -80,7 +80,7 @@ openclaw --version ## 4) Run Onboarding ```bash -openclaw setup --wizard --install-daemon +openclaw onboard --install-daemon ``` The wizard will walk you through: diff --git a/docs/platforms/index.md b/docs/platforms/index.md index 3c7ecca0f48..ec2663aefe4 100644 --- a/docs/platforms/index.md +++ b/docs/platforms/index.md @@ -42,7 +42,7 @@ Native companion apps for Windows are also planned; the Gateway is recommended v Use one of these (all supported): -- Wizard (recommended): `openclaw setup --wizard --install-daemon` +- Wizard (recommended): `openclaw onboard --install-daemon` - Direct: `openclaw gateway install` - Configure flow: `openclaw configure` → select **Gateway service** - Repair/migrate: `openclaw doctor` (offers to install or fix the service) diff --git a/docs/platforms/linux.md b/docs/platforms/linux.md index 29de3dd47ea..c03dba6f795 100644 --- a/docs/platforms/linux.md +++ b/docs/platforms/linux.md @@ -17,7 +17,7 @@ Native Linux companion apps are planned. Contributions are welcome if you want t 1. Install Node 24 (recommended; Node 22 LTS, currently `22.16+`, still works for compatibility) 2. `npm i -g openclaw@latest` -3. `openclaw setup --wizard --install-daemon` +3. `openclaw onboard --install-daemon` 4. From your laptop: `ssh -N -L 18789:127.0.0.1:18789 @` 5. Open `http://127.0.0.1:18789/` and paste your token @@ -39,7 +39,7 @@ Step-by-step VPS guide: [exe.dev](/install/exe-dev) Use one of these: ``` -openclaw setup --wizard --install-daemon +openclaw onboard --install-daemon ``` Or: diff --git a/docs/platforms/raspberry-pi.md b/docs/platforms/raspberry-pi.md index 4a3bf7b8204..2050b6395b4 100644 --- a/docs/platforms/raspberry-pi.md +++ b/docs/platforms/raspberry-pi.md @@ -130,7 +130,7 @@ The hackable install gives you direct access to logs and code — useful for deb ## 7) Run Onboarding ```bash -openclaw setup --wizard --install-daemon +openclaw onboard --install-daemon ``` Follow the wizard: diff --git a/docs/platforms/windows.md b/docs/platforms/windows.md index c8047271e65..e40d798604d 100644 --- a/docs/platforms/windows.md +++ b/docs/platforms/windows.md @@ -38,8 +38,8 @@ openclaw agent --local --agent main --thinking low -m "Reply with exactly WINDOW Current caveats: -- `openclaw setup --wizard --non-interactive` still expects a reachable local gateway unless you pass `--skip-health` -- `openclaw setup --wizard --non-interactive --install-daemon` and `openclaw gateway install` try Windows Scheduled Tasks first +- `openclaw onboard --non-interactive` still expects a reachable local gateway unless you pass `--skip-health` +- `openclaw onboard --non-interactive --install-daemon` and `openclaw gateway install` try Windows Scheduled Tasks first - if Scheduled Task creation is denied, OpenClaw falls back to a per-user Startup-folder login item and starts the gateway immediately - if `schtasks` itself wedges or stops responding, OpenClaw now aborts that path quickly and falls back instead of hanging forever - Scheduled Tasks are still preferred when available because they provide better supervisor status @@ -47,7 +47,7 @@ Current caveats: If you want the native CLI only, without gateway service install, use one of these: ```powershell -openclaw setup --wizard --non-interactive --skip-health +openclaw onboard --non-interactive --skip-health openclaw gateway run ``` @@ -70,7 +70,7 @@ If Scheduled Task creation is blocked, the fallback service mode still auto-star Inside WSL2: ``` -openclaw setup --wizard --install-daemon +openclaw onboard --install-daemon ``` Or: @@ -230,7 +230,7 @@ cd openclaw pnpm install pnpm ui:build # auto-installs UI deps on first run pnpm build -openclaw setup --wizard +openclaw onboard ``` Full guide: [Getting Started](/start/getting-started) diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md index 5611eec7ba4..d16d76f6315 100644 --- a/docs/providers/anthropic.md +++ b/docs/providers/anthropic.md @@ -19,11 +19,11 @@ Create your API key in the Anthropic Console. ### CLI setup ```bash -openclaw setup --wizard +openclaw onboard # choose: Anthropic API key # or non-interactive -openclaw setup --wizard --anthropic-api-key "$ANTHROPIC_API_KEY" +openclaw onboard --anthropic-api-key "$ANTHROPIC_API_KEY" ``` ### Config snippet @@ -214,7 +214,7 @@ openclaw models auth paste-token --provider anthropic ```bash # Paste a setup-token during setup -openclaw setup --wizard --auth-choice setup-token +openclaw onboard --auth-choice setup-token ``` ### Config snippet (setup-token) diff --git a/docs/providers/cloudflare-ai-gateway.md b/docs/providers/cloudflare-ai-gateway.md index 63f471413e8..392a611e705 100644 --- a/docs/providers/cloudflare-ai-gateway.md +++ b/docs/providers/cloudflare-ai-gateway.md @@ -22,7 +22,7 @@ For Anthropic models, use your Anthropic API key. 1. Set the provider API key and Gateway details: ```bash -openclaw setup --wizard --auth-choice cloudflare-ai-gateway-api-key +openclaw onboard --auth-choice cloudflare-ai-gateway-api-key ``` 2. Set a default model: @@ -40,7 +40,7 @@ openclaw setup --wizard --auth-choice cloudflare-ai-gateway-api-key ## Non-interactive example ```bash -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --mode local \ --auth-choice cloudflare-ai-gateway-api-key \ --cloudflare-ai-gateway-account-id "your-account-id" \ diff --git a/docs/providers/glm.md b/docs/providers/glm.md index bd096212cd0..64fe39a42df 100644 --- a/docs/providers/glm.md +++ b/docs/providers/glm.md @@ -15,16 +15,16 @@ models are accessed via the `zai` provider and model IDs like `zai/glm-5`. ```bash # Coding Plan Global, recommended for Coding Plan users -openclaw setup --wizard --auth-choice zai-coding-global +openclaw onboard --auth-choice zai-coding-global # Coding Plan CN (China region), recommended for Coding Plan users -openclaw setup --wizard --auth-choice zai-coding-cn +openclaw onboard --auth-choice zai-coding-cn # General API -openclaw setup --wizard --auth-choice zai-global +openclaw onboard --auth-choice zai-global # General API CN (China region) -openclaw setup --wizard --auth-choice zai-cn +openclaw onboard --auth-choice zai-cn ``` ## Config snippet diff --git a/docs/providers/huggingface.md b/docs/providers/huggingface.md index 416037dca49..7b33955f524 100644 --- a/docs/providers/huggingface.md +++ b/docs/providers/huggingface.md @@ -21,7 +21,7 @@ title: "Hugging Face (Inference)" 2. Run onboarding and choose **Hugging Face** in the provider dropdown, then enter your API key when prompted: ```bash -openclaw setup --wizard --auth-choice huggingface-api-key +openclaw onboard --auth-choice huggingface-api-key ``` 3. In the **Default Hugging Face model** dropdown, pick the model you want (the list is loaded from the Inference API when you have a valid token; otherwise a built-in list is shown). Your choice is saved as the default model. @@ -40,7 +40,7 @@ openclaw setup --wizard --auth-choice huggingface-api-key ## Non-interactive example ```bash -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --mode local \ --auth-choice huggingface-api-key \ --huggingface-api-key "$HF_TOKEN" diff --git a/docs/providers/index.md b/docs/providers/index.md index 0e5c181f56b..f68cd0e0b53 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -15,7 +15,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi ## Quick start -1. Authenticate with the provider (usually via `openclaw setup --wizard`). +1. Authenticate with the provider (usually via `openclaw onboard`). 2. Set the default model: ```json5 diff --git a/docs/providers/kilocode.md b/docs/providers/kilocode.md index b3d75e64bcf..15f8e4c2b7c 100644 --- a/docs/providers/kilocode.md +++ b/docs/providers/kilocode.md @@ -19,7 +19,7 @@ endpoint and API key. It is OpenAI-compatible, so most OpenAI SDKs work by switc ## CLI setup ```bash -openclaw setup --wizard --kilocode-api-key +openclaw onboard --kilocode-api-key ``` Or set the environment variable: diff --git a/docs/providers/litellm.md b/docs/providers/litellm.md index d96e1bb795c..51ad0d599f8 100644 --- a/docs/providers/litellm.md +++ b/docs/providers/litellm.md @@ -22,7 +22,7 @@ read_when: ### Via onboarding ```bash -openclaw setup --wizard --auth-choice litellm-api-key +openclaw onboard --auth-choice litellm-api-key ``` ### Manual setup diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index 7a39111f6c2..0d3635352cc 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -44,7 +44,7 @@ Enable the bundled OAuth plugin and authenticate: ```bash openclaw plugins enable minimax # skip if already loaded. openclaw gateway restart # restart if gateway is already running -openclaw setup --wizard --auth-choice minimax-portal +openclaw onboard --auth-choice minimax-portal ``` You will be prompted to select an endpoint: diff --git a/docs/providers/mistral.md b/docs/providers/mistral.md index 60a9e82853d..44e594abf21 100644 --- a/docs/providers/mistral.md +++ b/docs/providers/mistral.md @@ -15,9 +15,9 @@ Mistral can also be used for memory embeddings (`memorySearch.provider = "mistra ## CLI setup ```bash -openclaw setup --wizard --auth-choice mistral-api-key +openclaw onboard --auth-choice mistral-api-key # or non-interactive -openclaw setup --wizard --mistral-api-key "$MISTRAL_API_KEY" +openclaw onboard --mistral-api-key "$MISTRAL_API_KEY" ``` ## Config snippet (LLM provider) diff --git a/docs/providers/models.md b/docs/providers/models.md index 0bbff47c51e..a117d286051 100644 --- a/docs/providers/models.md +++ b/docs/providers/models.md @@ -13,7 +13,7 @@ model as `provider/model`. ## Quick start (two steps) -1. Authenticate with the provider (usually via `openclaw setup --wizard`). +1. Authenticate with the provider (usually via `openclaw onboard`). 2. Set the default model: ```json5 diff --git a/docs/providers/moonshot.md b/docs/providers/moonshot.md index de21a6ffb0a..daf9c881de5 100644 --- a/docs/providers/moonshot.md +++ b/docs/providers/moonshot.md @@ -26,13 +26,13 @@ Current Kimi K2 model IDs: [//]: # "moonshot-kimi-k2-ids:end" ```bash -openclaw setup --wizard --auth-choice moonshot-api-key +openclaw onboard --auth-choice moonshot-api-key ``` Kimi Coding: ```bash -openclaw setup --wizard --auth-choice kimi-code-api-key +openclaw onboard --auth-choice kimi-code-api-key ``` Note: Moonshot and Kimi Coding are separate providers. Keys are not interchangeable, endpoints differ, and model refs differ (Moonshot uses `moonshot/...`, Kimi Coding uses `kimi-coding/...`). diff --git a/docs/providers/nvidia.md b/docs/providers/nvidia.md index 2708d88db96..693a51db9b3 100644 --- a/docs/providers/nvidia.md +++ b/docs/providers/nvidia.md @@ -16,7 +16,7 @@ Export the key once, then run onboarding and set an NVIDIA model: ```bash export NVIDIA_API_KEY="nvapi-..." -openclaw setup --wizard --auth-choice skip +openclaw onboard --auth-choice skip openclaw models set nvidia/nvidia/llama-3.1-nemotron-70b-instruct ``` diff --git a/docs/providers/ollama.md b/docs/providers/ollama.md index db36f90a2da..5a1eb2bd27e 100644 --- a/docs/providers/ollama.md +++ b/docs/providers/ollama.md @@ -21,7 +21,7 @@ Ollama is a local LLM runtime that makes it easy to run open-source models on yo The fastest way to set up Ollama is through the setup wizard: ```bash -openclaw setup --wizard +openclaw onboard ``` Select **Ollama** from the provider list. The wizard will: @@ -35,7 +35,7 @@ Select **Ollama** from the provider list. The wizard will: Non-interactive mode is also supported: ```bash -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --auth-choice ollama \ --accept-risk ``` @@ -43,7 +43,7 @@ openclaw setup --wizard --non-interactive \ Optionally specify a custom base URL or model: ```bash -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --auth-choice ollama \ --custom-base-url "http://ollama-host:11434" \ --custom-model-id "qwen3.5:27b" \ @@ -73,7 +73,7 @@ ollama signin 4. Run onboarding and choose `Ollama`: ```bash -openclaw setup --wizard +openclaw onboard ``` - `Local`: local models only diff --git a/docs/providers/openai.md b/docs/providers/openai.md index 4f90d092838..a6a60f8f2ea 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -20,9 +20,9 @@ Get your API key from the OpenAI dashboard. ### CLI setup ```bash -openclaw setup --wizard --auth-choice openai-api-key +openclaw onboard --auth-choice openai-api-key # or non-interactive -openclaw setup --wizard --openai-api-key "$OPENAI_API_KEY" +openclaw onboard --openai-api-key "$OPENAI_API_KEY" ``` ### Config snippet @@ -52,7 +52,7 @@ Codex cloud requires ChatGPT sign-in, while the Codex CLI supports ChatGPT or AP ```bash # Run Codex OAuth in the wizard -openclaw setup --wizard --auth-choice openai-codex +openclaw onboard --auth-choice openai-codex # Or run OAuth directly openclaw models auth login --provider openai-codex diff --git a/docs/providers/opencode-go.md b/docs/providers/opencode-go.md index 2d826712977..4552e916beb 100644 --- a/docs/providers/opencode-go.md +++ b/docs/providers/opencode-go.md @@ -21,9 +21,9 @@ provider id `opencode-go` so upstream per-model routing stays correct. ## CLI setup ```bash -openclaw setup --wizard --auth-choice opencode-go +openclaw onboard --auth-choice opencode-go # or non-interactive -openclaw setup --wizard --opencode-go-api-key "$OPENCODE_API_KEY" +openclaw onboard --opencode-go-api-key "$OPENCODE_API_KEY" ``` ## Config snippet diff --git a/docs/providers/opencode.md b/docs/providers/opencode.md index 98eb2cfcbe0..da44e5154c0 100644 --- a/docs/providers/opencode.md +++ b/docs/providers/opencode.md @@ -22,15 +22,15 @@ as one OpenCode setup. ### Zen catalog ```bash -openclaw setup --wizard --auth-choice opencode-zen -openclaw setup --wizard --opencode-zen-api-key "$OPENCODE_API_KEY" +openclaw onboard --auth-choice opencode-zen +openclaw onboard --opencode-zen-api-key "$OPENCODE_API_KEY" ``` ### Go catalog ```bash -openclaw setup --wizard --auth-choice opencode-go -openclaw setup --wizard --opencode-go-api-key "$OPENCODE_API_KEY" +openclaw onboard --auth-choice opencode-go +openclaw onboard --opencode-go-api-key "$OPENCODE_API_KEY" ``` ## Config snippet diff --git a/docs/providers/openrouter.md b/docs/providers/openrouter.md index 4da33dbb1bc..5a9023481be 100644 --- a/docs/providers/openrouter.md +++ b/docs/providers/openrouter.md @@ -14,7 +14,7 @@ endpoint and API key. It is OpenAI-compatible, so most OpenAI SDKs work by switc ## CLI setup ```bash -openclaw setup --wizard --auth-choice apiKey --token-provider openrouter --token "$OPENROUTER_API_KEY" +openclaw onboard --auth-choice apiKey --token-provider openrouter --token "$OPENROUTER_API_KEY" ``` ## Config snippet diff --git a/docs/providers/qianfan.md b/docs/providers/qianfan.md index 9784dcc64dd..1e80dafb26b 100644 --- a/docs/providers/qianfan.md +++ b/docs/providers/qianfan.md @@ -27,7 +27,7 @@ endpoint and API key. It is OpenAI-compatible, so most OpenAI SDKs work by switc ## CLI setup ```bash -openclaw setup --wizard --auth-choice qianfan-api-key +openclaw onboard --auth-choice qianfan-api-key ``` ## Related Documentation diff --git a/docs/providers/sglang.md b/docs/providers/sglang.md index 96d33d5e767..ce66950c0c3 100644 --- a/docs/providers/sglang.md +++ b/docs/providers/sglang.md @@ -33,7 +33,7 @@ export SGLANG_API_KEY="sglang-local" 3. Run onboarding and choose `SGLang`, or set a model directly: ```bash -openclaw setup --wizard +openclaw onboard ``` ```json5 diff --git a/docs/providers/synthetic.md b/docs/providers/synthetic.md index 0e662320984..ae406a0e390 100644 --- a/docs/providers/synthetic.md +++ b/docs/providers/synthetic.md @@ -17,7 +17,7 @@ Synthetic exposes Anthropic-compatible endpoints. OpenClaw registers it as the 2. Run onboarding: ```bash -openclaw setup --wizard --auth-choice synthetic-api-key +openclaw onboard --auth-choice synthetic-api-key ``` The default model is set to: diff --git a/docs/providers/together.md b/docs/providers/together.md index e93224e5da3..62bab43a204 100644 --- a/docs/providers/together.md +++ b/docs/providers/together.md @@ -18,7 +18,7 @@ The [Together AI](https://together.ai) provides access to leading open-source mo 1. Set the API key (recommended: store it for the Gateway): ```bash -openclaw setup --wizard --auth-choice together-api-key +openclaw onboard --auth-choice together-api-key ``` 2. Set a default model: @@ -36,7 +36,7 @@ openclaw setup --wizard --auth-choice together-api-key ## Non-interactive example ```bash -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --mode local \ --auth-choice together-api-key \ --together-api-key "$TOGETHER_API_KEY" diff --git a/docs/providers/venice.md b/docs/providers/venice.md index a793239eb6f..520cf22d82b 100644 --- a/docs/providers/venice.md +++ b/docs/providers/venice.md @@ -58,7 +58,7 @@ export VENICE_API_KEY="vapi_xxxxxxxxxxxx" **Option B: Interactive Setup (Recommended)** ```bash -openclaw setup --wizard --auth-choice venice-api-key +openclaw onboard --auth-choice venice-api-key ``` This will: @@ -71,7 +71,7 @@ This will: **Option C: Non-interactive** ```bash -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --auth-choice venice-api-key \ --venice-api-key "vapi_xxxxxxxxxxxx" ``` diff --git a/docs/providers/vercel-ai-gateway.md b/docs/providers/vercel-ai-gateway.md index 55acf7f2ba7..f76e2b51bb5 100644 --- a/docs/providers/vercel-ai-gateway.md +++ b/docs/providers/vercel-ai-gateway.md @@ -21,7 +21,7 @@ The [Vercel AI Gateway](https://vercel.com/ai-gateway) provides a unified API to 1. Set the API key (recommended: store it for the Gateway): ```bash -openclaw setup --wizard --auth-choice ai-gateway-api-key +openclaw onboard --auth-choice ai-gateway-api-key ``` 2. Set a default model: @@ -39,7 +39,7 @@ openclaw setup --wizard --auth-choice ai-gateway-api-key ## Non-interactive example ```bash -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --mode local \ --auth-choice ai-gateway-api-key \ --ai-gateway-api-key "$AI_GATEWAY_API_KEY" diff --git a/docs/providers/xiaomi.md b/docs/providers/xiaomi.md index ec6ec043125..da1cf7fe38a 100644 --- a/docs/providers/xiaomi.md +++ b/docs/providers/xiaomi.md @@ -22,9 +22,9 @@ the `xiaomi` provider with a Xiaomi MiMo API key. ## CLI setup ```bash -openclaw setup --wizard --auth-choice xiaomi-api-key +openclaw onboard --auth-choice xiaomi-api-key # or non-interactive -openclaw setup --wizard --auth-choice xiaomi-api-key --xiaomi-api-key "$XIAOMI_API_KEY" +openclaw onboard --auth-choice xiaomi-api-key --xiaomi-api-key "$XIAOMI_API_KEY" ``` ## Config snippet diff --git a/docs/providers/zai.md b/docs/providers/zai.md index 86a0b3c6878..6f3aea27020 100644 --- a/docs/providers/zai.md +++ b/docs/providers/zai.md @@ -16,16 +16,16 @@ with a Z.AI API key. ```bash # Coding Plan Global, recommended for Coding Plan users -openclaw setup --wizard --auth-choice zai-coding-global +openclaw onboard --auth-choice zai-coding-global # Coding Plan CN (China region), recommended for Coding Plan users -openclaw setup --wizard --auth-choice zai-coding-cn +openclaw onboard --auth-choice zai-coding-cn # General API -openclaw setup --wizard --auth-choice zai-global +openclaw onboard --auth-choice zai-global # General API CN (China region) -openclaw setup --wizard --auth-choice zai-cn +openclaw onboard --auth-choice zai-cn ``` ## Config snippet diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md index b52aa74086d..5bfa3da7f9f 100644 --- a/docs/reference/wizard.md +++ b/docs/reference/wizard.md @@ -2,7 +2,7 @@ summary: "Full reference for the CLI setup wizard: every step, flag, and config field" read_when: - Looking up a specific wizard step or flag - - Automating setup with non-interactive mode + - Automating onboarding with non-interactive mode - Debugging wizard behavior title: "Setup Wizard Reference" sidebarTitle: "Wizard Reference" @@ -10,7 +10,7 @@ sidebarTitle: "Wizard Reference" # Setup Wizard Reference -This is the full reference for the `openclaw setup --wizard` CLI wizard. +This is the full reference for the `openclaw onboard` CLI wizard. For a high-level overview, see [Setup Wizard](/start/wizard). ## Flow details (local mode) @@ -76,11 +76,11 @@ For a high-level overview, see [Setup Wizard](/start/wizard). - In token mode, interactive setup offers: - **Generate/store plaintext token** (default) - **Use SecretRef** (opt-in) - - Quickstart reuses existing `gateway.auth.token` SecretRefs across `env`, `file`, and `exec` providers for setup probe/dashboard bootstrap. - - If that SecretRef is configured but cannot be resolved, setup fails early with a clear fix message instead of silently degrading runtime auth. + - Quickstart reuses existing `gateway.auth.token` SecretRefs across `env`, `file`, and `exec` providers for onboarding probe/dashboard bootstrap. + - If that SecretRef is configured but cannot be resolved, onboarding fails early with a clear fix message instead of silently degrading runtime auth. - In password mode, interactive setup also supports plaintext or SecretRef storage. - Non-interactive token SecretRef path: `--gateway-token-ref-env `. - - Requires a non-empty env var in the setup process environment. + - Requires a non-empty env var in the onboarding process environment. - Cannot be combined with `--gateway-token`. - Disable auth only if you fully trust every local process. - Non‑loopback binds still require auth. @@ -137,7 +137,7 @@ If the Control UI assets are missing, the wizard attempts to build them; fallbac Use `--non-interactive` to automate or script onboarding: ```bash -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --mode local \ --auth-choice apiKey \ --anthropic-api-key "$ANTHROPIC_API_KEY" \ @@ -154,7 +154,7 @@ Gateway token SecretRef in non-interactive mode: ```bash export OPENCLAW_GATEWAY_TOKEN="your-token" -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --mode local \ --auth-choice skip \ --gateway-auth token \ diff --git a/docs/start/getting-started.md b/docs/start/getting-started.md index af779afbe42..3fc64e5087d 100644 --- a/docs/start/getting-started.md +++ b/docs/start/getting-started.md @@ -54,7 +54,7 @@ Check your Node version with `node --version` if you are unsure. ```bash - openclaw setup --wizard --install-daemon + openclaw onboard --install-daemon ``` The wizard configures auth, gateway settings, and optional channels. diff --git a/docs/start/onboarding-overview.md b/docs/start/onboarding-overview.md index c2147252d2b..1e94a4db64a 100644 --- a/docs/start/onboarding-overview.md +++ b/docs/start/onboarding-overview.md @@ -1,18 +1,18 @@ --- -summary: "Overview of OpenClaw setup options and flows" +summary: "Overview of OpenClaw onboarding options and flows" read_when: - - Choosing a setup path + - Choosing an onboarding path - Setting up a new environment -title: "Setup Overview" -sidebarTitle: "Setup Overview" +title: "Onboarding Overview" +sidebarTitle: "Onboarding Overview" --- -# Setup Overview +# Onboarding Overview -OpenClaw supports multiple setup paths depending on where the Gateway runs +OpenClaw supports multiple onboarding paths depending on where the Gateway runs and how you prefer to configure providers. -## Choose your setup path +## Choose your onboarding path - **CLI wizard** for macOS, Linux, and Windows (via WSL2). - **macOS app** for a guided first run on Apple silicon or Intel Macs. @@ -22,14 +22,14 @@ and how you prefer to configure providers. Run the wizard in a terminal: ```bash -openclaw setup --wizard +openclaw onboard ``` Use the CLI wizard when you want full control of the Gateway, workspace, channels, and skills. Docs: - [Setup Wizard (CLI)](/start/wizard) -- [`openclaw setup --wizard` command](/cli/setup) +- [`openclaw onboard` command](/cli/onboard) ## macOS app onboarding @@ -48,4 +48,4 @@ CLI wizard. You will be asked to: - Provide a model ID and optional alias. - Choose an Endpoint ID so multiple custom endpoints can coexist. -For detailed steps, follow the CLI setup docs above. +For detailed steps, follow the CLI onboarding docs above. diff --git a/docs/start/wizard-cli-automation.md b/docs/start/wizard-cli-automation.md index 17803cefe48..884d49e143b 100644 --- a/docs/start/wizard-cli-automation.md +++ b/docs/start/wizard-cli-automation.md @@ -1,7 +1,7 @@ --- -summary: "Scripted setup wizard and agent setup for the OpenClaw CLI" +summary: "Scripted onboarding and agent setup for the OpenClaw CLI" read_when: - - You are automating setup in scripts or CI + - You are automating onboarding in scripts or CI - You need non-interactive examples for specific providers title: "CLI Automation" sidebarTitle: "CLI automation" @@ -9,7 +9,7 @@ sidebarTitle: "CLI automation" # CLI Automation -Use `--non-interactive` to automate `openclaw setup --wizard`. +Use `--non-interactive` to automate `openclaw onboard`. `--json` does not imply non-interactive mode. Use `--non-interactive` (and `--workspace`) for scripts. @@ -18,7 +18,7 @@ Use `--non-interactive` to automate `openclaw setup --wizard`. ## Baseline non-interactive example ```bash -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --mode local \ --auth-choice apiKey \ --anthropic-api-key "$ANTHROPIC_API_KEY" \ @@ -41,7 +41,7 @@ Passing inline key flags without the matching env var now fails fast. Example: ```bash -openclaw setup --wizard --non-interactive \ +openclaw onboard --non-interactive \ --mode local \ --auth-choice openai-api-key \ --secret-input-mode ref \ @@ -53,7 +53,7 @@ openclaw setup --wizard --non-interactive \ ```bash - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice gemini-api-key \ --gemini-api-key "$GEMINI_API_KEY" \ @@ -63,7 +63,7 @@ openclaw setup --wizard --non-interactive \ ```bash - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice zai-api-key \ --zai-api-key "$ZAI_API_KEY" \ @@ -73,7 +73,7 @@ openclaw setup --wizard --non-interactive \ ```bash - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice ai-gateway-api-key \ --ai-gateway-api-key "$AI_GATEWAY_API_KEY" \ @@ -83,7 +83,7 @@ openclaw setup --wizard --non-interactive \ ```bash - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice cloudflare-ai-gateway-api-key \ --cloudflare-ai-gateway-account-id "your-account-id" \ @@ -95,7 +95,7 @@ openclaw setup --wizard --non-interactive \ ```bash - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice moonshot-api-key \ --moonshot-api-key "$MOONSHOT_API_KEY" \ @@ -105,7 +105,7 @@ openclaw setup --wizard --non-interactive \ ```bash - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice mistral-api-key \ --mistral-api-key "$MISTRAL_API_KEY" \ @@ -115,7 +115,7 @@ openclaw setup --wizard --non-interactive \ ```bash - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice synthetic-api-key \ --synthetic-api-key "$SYNTHETIC_API_KEY" \ @@ -125,7 +125,7 @@ openclaw setup --wizard --non-interactive \ ```bash - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice opencode-zen \ --opencode-zen-api-key "$OPENCODE_API_KEY" \ @@ -136,7 +136,7 @@ openclaw setup --wizard --non-interactive \ ```bash - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice ollama \ --custom-model-id "qwen3.5:27b" \ @@ -147,7 +147,7 @@ openclaw setup --wizard --non-interactive \ ```bash - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice custom-api-key \ --custom-base-url "https://llm.example.com/v1" \ @@ -165,7 +165,7 @@ openclaw setup --wizard --non-interactive \ ```bash export CUSTOM_API_KEY="your-key" - openclaw setup --wizard --non-interactive \ + openclaw onboard --non-interactive \ --mode local \ --auth-choice custom-api-key \ --custom-base-url "https://llm.example.com/v1" \ @@ -212,4 +212,4 @@ Notes: - Onboarding hub: [Setup Wizard (CLI)](/start/wizard) - Full reference: [CLI Setup Reference](/start/wizard-cli-reference) -- Command reference: [`openclaw setup --wizard`](/cli/setup) +- Command reference: [`openclaw onboard`](/cli/onboard) diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index 2a2bac76528..36bd836a13f 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -1,15 +1,15 @@ --- summary: "Complete reference for CLI setup flow, auth/model setup, outputs, and internals" read_when: - - You need detailed behavior for `openclaw setup --wizard` - - You are debugging setup results or integrating setup clients + - You need detailed behavior for openclaw onboard + - You are debugging onboarding results or integrating onboarding clients title: "CLI Setup Reference" sidebarTitle: "CLI reference" --- # CLI Setup Reference -This page is the full reference for `openclaw setup --wizard`. +This page is the full reference for `openclaw onboard`. For the short guide, see [Setup Wizard (CLI)](/start/wizard). ## What the wizard does @@ -56,7 +56,7 @@ It does not install or modify anything on the remote host. - **Use SecretRef** (opt-in) - In password mode, interactive setup also supports plaintext or SecretRef storage. - Non-interactive token SecretRef path: `--gateway-token-ref-env `. - - Requires a non-empty env var in the setup process environment. + - Requires a non-empty env var in the onboarding process environment. - Cannot be combined with `--gateway-token`. - Disable auth only if you fully trust every local process. - Non-loopback binds still require auth. @@ -220,20 +220,20 @@ Credential and profile paths: Credential storage mode: -- Default setup behavior persists API keys as plaintext values in auth profiles. +- Default onboarding behavior persists API keys as plaintext values in auth profiles. - `--secret-input-mode ref` enables reference mode instead of plaintext key storage. In interactive setup, you can choose either: - environment variable ref (for example `keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }`) - configured provider ref (`file` or `exec`) with provider alias + id - Interactive reference mode runs a fast preflight validation before saving. - - Env refs: validates variable name + non-empty value in the current setup environment. + - Env refs: validates variable name + non-empty value in the current onboarding environment. - Provider refs: validates provider config and resolves the requested id. - - If preflight fails, setup shows the error and lets you retry. + - If preflight fails, onboarding shows the error and lets you retry. - In non-interactive mode, `--secret-input-mode ref` is env-backed only. - - Set the provider env var in the setup process environment. - - Inline key flags (for example `--openai-api-key`) require that env var to be set; otherwise setup fails fast. + - Set the provider env var in the onboarding process environment. + - Inline key flags (for example `--openai-api-key`) require that env var to be set; otherwise onboarding fails fast. - For custom providers, non-interactive `ref` mode stores `models.providers..apiKey` as `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`. - - In that custom-provider case, `--custom-api-key` requires `CUSTOM_API_KEY` to be set; otherwise setup fails fast. + - In that custom-provider case, `--custom-api-key` requires `CUSTOM_API_KEY` to be set; otherwise onboarding fails fast. - Gateway auth credentials support plaintext and SecretRef choices in interactive setup: - Token mode: **Generate/store plaintext token** (default) or **Use SecretRef**. - Password mode: plaintext or SecretRef. @@ -252,9 +252,9 @@ Typical fields in `~/.openclaw/openclaw.json`: - `agents.defaults.workspace` - `agents.defaults.model` / `models.providers` (if Minimax chosen) -- `tools.profile` (local setup defaults to `"coding"` when unset; existing explicit values are preserved) +- `tools.profile` (local onboarding defaults to `"coding"` when unset; existing explicit values are preserved) - `gateway.*` (mode, bind, auth, tailscale) -- `session.dmScope` (local setup defaults this to `per-channel-peer` when unset; existing explicit values are preserved) +- `session.dmScope` (local onboarding defaults this to `per-channel-peer` when unset; existing explicit values are preserved) - `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*` - Channel allowlists (Slack, Discord, Matrix, Microsoft Teams) when you opt in during prompts (names resolve to IDs when possible) - `skills.install.nodeManager` @@ -296,4 +296,4 @@ Signal setup behavior: - Onboarding hub: [Setup Wizard (CLI)](/start/wizard) - Automation and scripts: [CLI Automation](/start/wizard-cli-automation) -- Command reference: [`openclaw setup --wizard`](/cli/setup) +- Command reference: [`openclaw onboard`](/cli/onboard) diff --git a/docs/start/wizard.md b/docs/start/wizard.md index fe887ea9a4f..7bbe9df64cf 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -4,7 +4,7 @@ read_when: - Running or configuring the setup wizard - Setting up a new machine title: "Setup Wizard (CLI)" -sidebarTitle: "Setup: CLI" +sidebarTitle: "Onboarding: CLI" --- # Setup Wizard (CLI) @@ -15,7 +15,7 @@ It configures a local Gateway or a remote Gateway connection, plus channels, ski and workspace defaults in one guided flow. ```bash -openclaw setup --wizard +openclaw onboard ``` @@ -52,7 +52,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control). - Gateway port **18789** - Gateway auth **Token** (auto‑generated, even on loopback) - Tool policy default for new local setups: `tools.profile: "coding"` (existing explicit profile is preserved) - - DM isolation default: local setup writes `session.dmScope: "per-channel-peer"` when unset. Details: [CLI Setup Reference](/start/wizard-cli-reference#outputs-and-internals) + - DM isolation default: local onboarding writes `session.dmScope: "per-channel-peer"` when unset. Details: [CLI Setup Reference](/start/wizard-cli-reference#outputs-and-internals) - Tailscale exposure **Off** - Telegram + WhatsApp DMs default to **allowlist** (you'll be prompted for your phone number) @@ -119,7 +119,7 @@ For the deeper technical reference, including RPC details, see ## Related docs -- CLI command reference: [`openclaw setup`](/cli/setup) -- Setup overview: [Setup Overview](/start/onboarding-overview) +- CLI command reference: [`openclaw onboard`](/cli/onboard) +- Onboarding overview: [Onboarding Overview](/start/onboarding-overview) - macOS app onboarding: [Onboarding](/start/onboarding) - Agent first-run ritual: [Agent Bootstrapping](/start/bootstrapping) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 560d25930d5..c14f3c39f56 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1195,11 +1195,11 @@ A provider plugin can participate in five distinct phases: `auth[].run(ctx)` performs OAuth, API-key capture, device code, or custom setup and returns auth profiles plus optional config patches. 2. **Non-interactive setup** - `auth[].runNonInteractive(ctx)` handles `openclaw setup --wizard --non-interactive` + `auth[].runNonInteractive(ctx)` handles `openclaw onboard --non-interactive` without prompts. Use this when the provider needs custom headless setup beyond the built-in simple API-key paths. 3. **Wizard integration** - `wizard.setup` adds an entry to `openclaw setup --wizard`. + `wizard.setup` adds an entry to `openclaw onboard`. `wizard.modelPicker` adds a setup entry to the model picker. 4. **Implicit discovery** `discovery.run(ctx)` can contribute provider config automatically during @@ -1360,7 +1360,7 @@ or more auth methods (OAuth, API key, device code, etc.). Those methods can power: - `openclaw models auth login --provider [--method ]` -- `openclaw setup --wizard` +- `openclaw onboard` - model-picker “custom provider” setup entries - implicit provider discovery during model resolution/listing @@ -1435,7 +1435,7 @@ Notes: for headless onboarding. - Return `configPatch` when you need to add default models or provider config. - Return `defaultModel` so `--set-default` can update agent defaults. -- `wizard.setup` adds a provider choice to `openclaw setup --wizard`. +- `wizard.setup` adds a provider choice to `openclaw onboard`. - `wizard.modelPicker` adds a “setup this provider” entry to the model picker. - `discovery.run` returns either `{ provider }` for the plugin’s own provider id or `{ providers }` for multi-provider discovery. diff --git a/src/cli/program/register.setup.ts b/src/cli/program/register.setup.ts index 95888cb236a..33893d945bb 100644 --- a/src/cli/program/register.setup.ts +++ b/src/cli/program/register.setup.ts @@ -10,7 +10,7 @@ import { hasExplicitOptions } from "../command-options.js"; export function registerSetupCommand(program: Command) { program .command("setup") - .description("Initialize config/workspace or run the setup wizard") + .description("Initialize ~/.openclaw/openclaw.json and the agent workspace") .addHelpText( "after", () => @@ -20,8 +20,8 @@ export function registerSetupCommand(program: Command) { "--workspace ", "Agent workspace directory (default: ~/.openclaw/workspace; stored as agents.defaults.workspace)", ) - .option("--wizard", "Run the guided setup wizard", false) - .option("--non-interactive", "Run the setup wizard without prompts", false) + .option("--wizard", "Run the interactive onboarding wizard", false) + .option("--non-interactive", "Run the wizard without prompts", false) .option("--mode ", "Wizard mode: local|remote") .option("--remote-url ", "Remote Gateway WebSocket URL") .option("--remote-token ", "Remote Gateway token (optional)") diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index 8eb16fb2c07..c9af3fbf937 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -61,7 +61,7 @@ export async function setupWizardCommand( [ "Non-interactive setup requires explicit risk acknowledgement.", "Read: https://docs.openclaw.ai/security", - `Re-run with: ${formatCliCommand("openclaw setup --wizard --non-interactive --accept-risk ...")}`, + `Re-run with: ${formatCliCommand("openclaw onboard --non-interactive --accept-risk ...")}`, ].join("\n"), ); runtime.exit(1); diff --git a/src/commands/reset.ts b/src/commands/reset.ts index eca1d78e7c1..596d80a139a 100644 --- a/src/commands/reset.ts +++ b/src/commands/reset.ts @@ -134,7 +134,7 @@ export async function resetCommand(runtime: RuntimeEnv, opts: ResetOptions) { for (const dir of sessionDirs) { await removePath(dir, runtime, { dryRun, label: dir }); } - runtime.log(`Next: ${formatCliCommand("openclaw setup --wizard --install-daemon")}`); + runtime.log(`Next: ${formatCliCommand("openclaw onboard --install-daemon")}`); return; } @@ -145,7 +145,7 @@ export async function resetCommand(runtime: RuntimeEnv, opts: ResetOptions) { { dryRun }, ); await removeWorkspaceDirs(workspaceDirs, runtime, { dryRun }); - runtime.log(`Next: ${formatCliCommand("openclaw setup --wizard --install-daemon")}`); + runtime.log(`Next: ${formatCliCommand("openclaw onboard --install-daemon")}`); return; } } From 2580b81bd217702c9302072e6a70de9b90f64b9b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 22:52:56 -0700 Subject: [PATCH 05/23] refactor: move channel capability diagnostics into plugins --- docs/refactor/plugin-sdk.md | 2 + extensions/discord/src/channel.ts | 104 +++++ extensions/msteams/src/channel.ts | 45 +++ extensions/signal/src/channel.ts | 5 + extensions/slack/src/channel.ts | 58 +++ extensions/telegram/src/channel.ts | 27 +- src/auto-reply/reply/route-reply.test.ts | 7 +- src/auto-reply/reply/route-reply.ts | 29 +- .../plugins/message-actions.security.test.ts | 2 + src/channels/plugins/message-actions.ts | 18 +- src/channels/plugins/types.adapters.ts | 29 ++ src/channels/plugins/types.core.ts | 2 + src/channels/plugins/types.ts | 3 + src/channels/reply-prefix.ts | 7 +- src/commands/channels/add.ts | 16 - src/commands/channels/capabilities.test.ts | 36 +- src/commands/channels/capabilities.ts | 360 +++--------------- src/commands/channels/remove.ts | 5 - 18 files changed, 363 insertions(+), 392 deletions(-) diff --git a/docs/refactor/plugin-sdk.md b/docs/refactor/plugin-sdk.md index 05d519a0d24..5a630982a97 100644 --- a/docs/refactor/plugin-sdk.md +++ b/docs/refactor/plugin-sdk.md @@ -223,6 +223,8 @@ channel-specific UX and routing behavior: - `messaging.enableInteractiveReplies`: channel-owned reply normalization toggles (for example Slack interactive replies) - `messaging.resolveOutboundSessionRoute`: channel-owned outbound session routing +- `status.formatCapabilitiesProbe` / `status.buildCapabilitiesDiagnostics`: channel-owned + `/channels capabilities` probe display and extra audits/scopes - `threading.resolveAutoThreadId`: channel-owned same-conversation auto-threading - `threading.resolveReplyTransport`: channel-owned reply-vs-thread delivery mapping - `actions.requiresTrustedRequesterSender`: channel-owned privileged action trust gates diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 26a69cf79e0..1b0e003202c 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -38,8 +38,11 @@ import { import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { normalizeMessageChannel } from "../../../src/utils/message-channel.js"; import { isDiscordExecApprovalClientEnabled } from "./exec-approvals.js"; +import type { DiscordProbe } from "./probe.js"; import { getDiscordRuntime } from "./runtime.js"; +import { fetchChannelPermissionsDiscord } from "./send.js"; import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js"; +import { parseDiscordTarget } from "./targets.js"; import { DiscordUiContainer } from "./ui.js"; type DiscordSendFn = ReturnType< @@ -47,11 +50,27 @@ type DiscordSendFn = ReturnType< >["channel"]["discord"]["sendMessageDiscord"]; const meta = getChatChannelMeta("discord"); +const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const; async function loadDiscordChannelRuntime() { return await import("./channel.runtime.js"); } +function formatDiscordIntents(intents?: { + messageContent?: string; + guildMembers?: string; + presence?: string; +}) { + if (!intents) { + return "unknown"; + } + return [ + `messageContent=${intents.messageContent ?? "unknown"}`, + `guildMembers=${intents.guildMembers ?? "unknown"}`, + `presence=${intents.presence ?? "unknown"}`, + ].join(" "); +} + const discordMessageActions: ChannelMessageActionAdapter = { listActions: (ctx) => getDiscordRuntime().channel.discord.messageActions?.listActions?.(ctx) ?? [], @@ -355,6 +374,91 @@ export const discordPlugin: ChannelPlugin = { getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, { includeApplication: true, }), + formatCapabilitiesProbe: ({ probe }) => { + const discordProbe = probe as DiscordProbe | undefined; + const lines = []; + if (discordProbe?.bot?.username) { + const botId = discordProbe.bot.id ? ` (${discordProbe.bot.id})` : ""; + lines.push({ text: `Bot: @${discordProbe.bot.username}${botId}` }); + } + if (discordProbe?.application?.intents) { + lines.push({ text: `Intents: ${formatDiscordIntents(discordProbe.application.intents)}` }); + } + return lines; + }, + buildCapabilitiesDiagnostics: async ({ account, timeoutMs, target }) => { + if (!target?.trim()) { + return undefined; + } + const parsedTarget = parseDiscordTarget(target.trim(), { defaultKind: "channel" }); + const details: Record = { + target: { + raw: target, + normalized: parsedTarget?.normalized, + kind: parsedTarget?.kind, + channelId: parsedTarget?.kind === "channel" ? parsedTarget.id : undefined, + }, + }; + if (!parsedTarget || parsedTarget.kind !== "channel") { + return { + details, + lines: [ + { + text: "Permissions: Target looks like a DM user; pass channel: to audit channel permissions.", + tone: "error", + }, + ], + }; + } + const token = account.token?.trim(); + if (!token) { + return { + details, + lines: [ + { + text: "Permissions: Discord bot token missing for permission audit.", + tone: "error", + }, + ], + }; + } + try { + const perms = await fetchChannelPermissionsDiscord(parsedTarget.id, { + token, + accountId: account.accountId ?? undefined, + }); + const missingRequired = REQUIRED_DISCORD_PERMISSIONS.filter( + (permission) => !perms.permissions.includes(permission), + ); + details.permissions = { + channelId: perms.channelId, + guildId: perms.guildId, + isDm: perms.isDm, + channelType: perms.channelType, + permissions: perms.permissions, + missingRequired, + raw: perms.raw, + }; + return { + details, + lines: [ + { + text: `Permissions (${perms.channelId}): ${perms.permissions.length ? perms.permissions.join(", ") : "none"}`, + }, + missingRequired.length > 0 + ? { text: `Missing required: ${missingRequired.join(", ")}`, tone: "warn" } + : { text: "Missing required: none", tone: "success" }, + ], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + details.permissions = { channelId: parsedTarget.id, error: message }; + return { + details, + lines: [{ text: `Permissions: ${message}`, tone: "error" }], + }; + } + }, auditAccount: async ({ account, timeoutMs, cfg }) => { const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({ cfg, diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index a21aa451eb8..c4d3f41054c 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -17,6 +17,7 @@ import { PAIRING_APPROVED_MESSAGE, } from "openclaw/plugin-sdk/msteams"; import { resolveMSTeamsGroupToolPolicy } from "./policy.js"; +import type { ProbeMSTeamsResult } from "./probe.js"; import { normalizeMSTeamsMessagingTarget, normalizeMSTeamsUserInput, @@ -47,6 +48,16 @@ const meta = { order: 60, } as const; +const TEAMS_GRAPH_PERMISSION_HINTS: Record = { + "ChannelMessage.Read.All": "channel history", + "Chat.Read.All": "chat history", + "Channel.ReadBasic.All": "channel list", + "Team.ReadBasic.All": "team list", + "TeamsActivity.Read.All": "teams activity", + "Sites.Read.All": "files (SharePoint)", + "Files.Read.All": "files (OneDrive)", +}; + async function loadMSTeamsChannelRuntime() { return await import("./channel.runtime.js"); } @@ -435,6 +446,40 @@ export const msteamsPlugin: ChannelPlugin = { }), probeAccount: async ({ cfg }) => await (await loadMSTeamsChannelRuntime()).probeMSTeams(cfg.channels?.msteams), + formatCapabilitiesProbe: ({ probe }) => { + const teamsProbe = probe as ProbeMSTeamsResult | undefined; + const lines: Array<{ text: string; tone?: "error" }> = []; + const appId = typeof teamsProbe?.appId === "string" ? teamsProbe.appId.trim() : ""; + if (appId) { + lines.push({ text: `App: ${appId}` }); + } + const graph = teamsProbe?.graph; + if (graph) { + const roles = Array.isArray(graph.roles) + ? graph.roles.map((role) => String(role).trim()).filter(Boolean) + : []; + const scopes = Array.isArray(graph.scopes) + ? graph.scopes.map((scope) => String(scope).trim()).filter(Boolean) + : []; + const formatPermission = (permission: string) => { + const hint = TEAMS_GRAPH_PERMISSION_HINTS[permission]; + return hint ? `${permission} (${hint})` : permission; + }; + if (graph.ok === false) { + lines.push({ text: `Graph: ${graph.error ?? "failed"}`, tone: "error" }); + } else if (roles.length > 0 || scopes.length > 0) { + if (roles.length > 0) { + lines.push({ text: `Graph roles: ${roles.map(formatPermission).join(", ")}` }); + } + if (scopes.length > 0) { + lines.push({ text: `Graph scopes: ${scopes.map(formatPermission).join(", ")}` }); + } + } else if (graph.ok === true) { + lines.push({ text: "Graph: ok" }); + } + } + return lines; + }, buildAccountSnapshot: ({ account, runtime, probe }) => ({ accountId: account.accountId, enabled: account.enabled, diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 8b2f0998ff9..010df26d390 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -27,6 +27,7 @@ import { type ResolvedSignalAccount, } from "openclaw/plugin-sdk/signal"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; +import type { SignalProbe } from "./probe.js"; import { getSignalRuntime } from "./runtime.js"; import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js"; @@ -220,6 +221,10 @@ export const signalPlugin: ChannelPlugin = { const baseUrl = account.baseUrl; return await getSignalRuntime().channel.signal.probeSignal(baseUrl, timeoutMs); }, + formatCapabilitiesProbe: ({ probe }) => + (probe as SignalProbe | undefined)?.version + ? [{ text: `Signal daemon: ${(probe as SignalProbe).version}` }] + : [], buildAccountSnapshot: ({ account, runtime, probe }) => ({ ...buildBaseAccountStatusSnapshot({ account, runtime, probe }), baseUrl: account.baseUrl, diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 04c9706bd95..f658b93d2c3 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -36,7 +36,10 @@ import { } from "openclaw/plugin-sdk/slack"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; +import { parseSlackBlocksInput } from "./blocks-input.js"; +import type { SlackProbe } from "./probe.js"; import { getSlackRuntime } from "./runtime.js"; +import { fetchSlackScopes } from "./scopes.js"; import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; import { parseSlackTarget } from "./targets.js"; @@ -126,6 +129,21 @@ function resolveSlackAutoThreadId(params: { return context.currentThreadTs; } +function formatSlackScopeDiagnostic(params: { + tokenType: "bot" | "user"; + result: Awaited>; +}) { + const source = params.result.source ? ` (${params.result.source})` : ""; + const label = params.tokenType === "user" ? "User scopes" : "Bot scopes"; + if (params.result.ok && params.result.scopes?.length) { + return { text: `${label}${source}: ${params.result.scopes.join(", ")}` } as const; + } + return { + text: `${label}: ${params.result.error ?? "scope lookup failed"}`, + tone: "error", + } as const; +} + const slackConfigAccessors = createScopedAccountConfigAccessors({ resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }), resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom, @@ -285,6 +303,17 @@ export const slackPlugin: ChannelPlugin = { normalizeTarget: normalizeSlackMessagingTarget, enableInteractiveReplies: ({ cfg, accountId }) => isSlackInteractiveRepliesEnabled({ cfg, accountId }), + hasStructuredReplyPayload: ({ payload }) => { + const slackData = payload.channelData?.slack; + if (!slackData || typeof slackData !== "object" || Array.isArray(slackData)) { + return false; + } + try { + return Boolean(parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks)?.length); + } catch { + return false; + } + }, targetResolver: { looksLikeId: looksLikeSlackTargetId, hint: "", @@ -429,6 +458,35 @@ export const slackPlugin: ChannelPlugin = { } return await getSlackRuntime().channel.slack.probeSlack(token, timeoutMs); }, + formatCapabilitiesProbe: ({ probe }) => { + const slackProbe = probe as SlackProbe | undefined; + const lines = []; + if (slackProbe?.bot?.name) { + lines.push({ text: `Bot: @${slackProbe.bot.name}` }); + } + if (slackProbe?.team?.name || slackProbe?.team?.id) { + const id = slackProbe.team?.id ? ` (${slackProbe.team.id})` : ""; + lines.push({ text: `Team: ${slackProbe.team?.name ?? "unknown"}${id}` }); + } + return lines; + }, + buildCapabilitiesDiagnostics: async ({ account, timeoutMs }) => { + const lines = []; + const details: Record = {}; + const botToken = account.botToken?.trim(); + const userToken = account.config.userToken?.trim(); + const botScopes = botToken + ? await fetchSlackScopes(botToken, timeoutMs) + : { ok: false, error: "Slack bot token missing." }; + lines.push(formatSlackScopeDiagnostic({ tokenType: "bot", result: botScopes })); + details.botScopes = botScopes; + if (userToken) { + const userScopes = await fetchSlackScopes(userToken, timeoutMs); + lines.push(formatSlackScopeDiagnostic({ tokenType: "user", result: userScopes })); + details.userScopes = userScopes; + } + return { lines, details }; + }, buildAccountSnapshot: ({ account, runtime, probe }) => { const mode = account.config.mode ?? "socket"; const configured = diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 1f0d94057a2..2aebfe5652c 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -54,7 +54,6 @@ import { sendTypingTelegram } from "./send.js"; import { telegramSetupAdapter } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; import { parseTelegramTarget } from "./targets.js"; -import { deleteTelegramUpdateOffset } from "./update-offset-store.js"; type TelegramSendFn = ReturnType< typeof getTelegramRuntime @@ -334,10 +333,12 @@ export const telegramPlugin: ChannelPlugin { + const { deleteTelegramUpdateOffset } = await import("./update-offset-store.js"); await deleteTelegramUpdateOffset({ accountId }); }, }, @@ -515,6 +516,30 @@ export const telegramPlugin: ChannelPlugin { + const lines = []; + if (probe?.bot?.username) { + const botId = probe.bot.id ? ` (${probe.bot.id})` : ""; + lines.push({ text: `Bot: @${probe.bot.username}${botId}` }); + } + const flags: string[] = []; + if (typeof probe?.bot?.canJoinGroups === "boolean") { + flags.push(`joinGroups=${probe.bot.canJoinGroups}`); + } + if (typeof probe?.bot?.canReadAllGroupMessages === "boolean") { + flags.push(`readAllGroupMessages=${probe.bot.canReadAllGroupMessages}`); + } + if (typeof probe?.bot?.supportsInlineQueries === "boolean") { + flags.push(`inlineQueries=${probe.bot.supportsInlineQueries}`); + } + if (flags.length > 0) { + lines.push({ text: `Flags: ${flags.join(" ")}` }); + } + if (probe?.webhook?.url !== undefined) { + lines.push({ text: `Webhook: ${probe.webhook.url || "none"}` }); + } + return lines; + }, auditAccount: async ({ account, timeoutMs, probe, cfg }) => { const groups = cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ?? diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 0a717f9bfc7..c0023ae1c37 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mattermostPlugin } from "../../../extensions/mattermost/src/channel.js"; +import { slackPlugin } from "../../../extensions/slack/src/channel.js"; import { discordOutbound } from "../../channels/plugins/outbound/discord.js"; import { imessageOutbound } from "../../channels/plugins/outbound/imessage.js"; import { signalOutbound } from "../../channels/plugins/outbound/signal.js"; @@ -540,7 +541,11 @@ const defaultRegistry = createTestRegistry([ }, { pluginId: "slack", - plugin: createOutboundTestPlugin({ id: "slack", outbound: slackOutbound, label: "Slack" }), + plugin: { + ...createOutboundTestPlugin({ id: "slack", outbound: slackOutbound, label: "Slack" }), + messaging: slackPlugin.messaging, + threading: slackPlugin.threading, + }, source: "test", }, { diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 15036d0878f..8dc7499526a 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -7,8 +7,6 @@ * across multiple providers. */ -import { parseSlackBlocksInput } from "../../../extensions/slack/src/blocks-input.js"; -import { isSlackInteractiveRepliesEnabled } from "../../../extensions/slack/src/interactive-replies.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveEffectiveMessagesConfig } from "../../agents/identity.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; @@ -101,9 +99,10 @@ export async function routeReply(params: RouteReplyParams): Promise 0; - let hasChannelData = - externalPayload.channelData != null && Object.keys(externalPayload.channelData).length > 0; - if ( - channel === "slack" && - externalPayload.channelData?.slack && - typeof externalPayload.channelData.slack === "object" && - !Array.isArray(externalPayload.channelData.slack) - ) { - try { - hasChannelData = Boolean( - parseSlackBlocksInput((externalPayload.channelData.slack as { blocks?: unknown }).blocks) - ?.length, - ); - } catch { - hasChannelData = false; - } - } + const hasChannelData = plugin?.messaging?.hasStructuredReplyPayload?.({ + payload: externalPayload, + }); // Skip empty replies. if (!text.trim() && mediaUrls.length === 0 && !hasInteractive && !hasChannelData) { diff --git a/src/channels/plugins/message-actions.security.test.ts b/src/channels/plugins/message-actions.security.test.ts index 1dbd19de3e0..b8b62afdecd 100644 --- a/src/channels/plugins/message-actions.security.test.ts +++ b/src/channels/plugins/message-actions.security.test.ts @@ -25,6 +25,8 @@ const discordPlugin: ChannelPlugin = { actions: { listActions: () => ["kick"], supportsAction: ({ action }) => action === "kick", + requiresTrustedRequesterSender: ({ action, toolContext }) => + Boolean(action === "kick" && toolContext), handleAction, }, }; diff --git a/src/channels/plugins/message-actions.ts b/src/channels/plugins/message-actions.ts index 53bc14cfc10..506f2204493 100644 --- a/src/channels/plugins/message-actions.ts +++ b/src/channels/plugins/message-actions.ts @@ -6,23 +6,13 @@ import type { ChannelMessageActionContext, ChannelMessageActionName } from "./ty type ChannelActions = NonNullable>["actions"]>; -const trustedRequesterRequiredByChannel: Readonly< - Partial>> -> = { - discord: new Set(["timeout", "kick", "ban"]), -}; - function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boolean { const plugin = getChannelPlugin(ctx.channel); - const fromPlugin = plugin?.actions?.requiresTrustedRequesterSender?.({ - action: ctx.action, - toolContext: ctx.toolContext, - }); - if (fromPlugin != null) { - return fromPlugin; - } return Boolean( - trustedRequesterRequiredByChannel[ctx.channel]?.has(ctx.action) && ctx.toolContext, + plugin?.actions?.requiresTrustedRequesterSender?.({ + action: ctx.action, + toolContext: ctx.toolContext, + }), ); } diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index c8255f07542..084fa653bb8 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -35,6 +35,22 @@ export type ChannelExecApprovalForwardTarget = { source?: "session" | "target"; }; +export type ChannelCapabilitiesDisplayTone = "default" | "muted" | "success" | "warn" | "error"; + +export type ChannelCapabilitiesDisplayLine = { + text: string; + tone?: ChannelCapabilitiesDisplayTone; +}; + +export type ChannelCapabilitiesDiagnostics = { + lines?: ChannelCapabilitiesDisplayLine[]; + details?: Record; +}; + +type BivariantCallback unknown> = { + bivarianceHack: T; +}["bivarianceHack"]; + export type ChannelSetupAdapter = { resolveAccountId?: (params: { cfg: OpenClawConfig; @@ -153,12 +169,25 @@ export type ChannelStatusAdapter Promise; + formatCapabilitiesProbe?: BivariantCallback< + (params: { probe: Probe }) => ChannelCapabilitiesDisplayLine[] + >; auditAccount?: (params: { account: ResolvedAccount; timeoutMs: number; cfg: OpenClawConfig; probe?: Probe; }) => Promise; + buildCapabilitiesDiagnostics?: BivariantCallback< + (params: { + account: ResolvedAccount; + timeoutMs: number; + cfg: OpenClawConfig; + probe?: Probe; + audit?: Audit; + target?: string; + }) => Promise + >; buildAccountSnapshot?: (params: { account: ResolvedAccount; cfg: OpenClawConfig; diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 22b2c9387e7..4d94afe49fd 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -2,6 +2,7 @@ import type { TopLevelComponents } from "@buape/carbon"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import type { TSchema } from "@sinclair/typebox"; import type { MsgContext } from "../../auto-reply/templating.js"; +import type { ReplyPayload } from "../../auto-reply/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { PollInput } from "../../polls.js"; import type { GatewayClientMode, GatewayClientName } from "../../utils/message-channel.js"; @@ -349,6 +350,7 @@ export type ChannelMessagingAdapter = { cfg: OpenClawConfig; accountId?: string | null; }) => boolean; + hasStructuredReplyPayload?: (params: { payload: ReplyPayload }) => boolean; targetResolver?: { looksLikeId?: (raw: string, normalized?: string) => boolean; hint?: string; diff --git a/src/channels/plugins/types.ts b/src/channels/plugins/types.ts index a2abcc12dea..ffa098f0673 100644 --- a/src/channels/plugins/types.ts +++ b/src/channels/plugins/types.ts @@ -9,6 +9,9 @@ export type { ChannelMessageCapability } from "./message-capabilities.js"; export type { ChannelAuthAdapter, ChannelCommandAdapter, + ChannelCapabilitiesDiagnostics, + ChannelCapabilitiesDisplayLine, + ChannelCapabilitiesDisplayTone, ChannelConfigAdapter, ChannelDirectoryAdapter, ChannelExecApprovalAdapter, diff --git a/src/channels/reply-prefix.ts b/src/channels/reply-prefix.ts index c76b6175157..cfda423eeb9 100644 --- a/src/channels/reply-prefix.ts +++ b/src/channels/reply-prefix.ts @@ -1,4 +1,3 @@ -import { isSlackInteractiveRepliesEnabled } from "../../extensions/slack/src/interactive-replies.js"; import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../agents/identity.js"; import { extractShortModelName, @@ -55,11 +54,7 @@ export function createReplyPrefixContext(params: { ? (getChannelPlugin(params.channel)?.messaging?.enableInteractiveReplies?.({ cfg, accountId: params.accountId, - }) ?? - (params.channel === "slack" - ? isSlackInteractiveRepliesEnabled({ cfg, accountId: params.accountId }) - : undefined) ?? - undefined) + }) ?? undefined) : undefined, responsePrefixContextProvider: () => prefixContext, onModelSelected, diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 0079e7ea881..4f8b3e8133c 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -321,12 +321,6 @@ export async function channelsAddCommand( }); } - let previousTelegramToken = ""; - if (channel === "telegram") { - const { resolveTelegramAccount } = await import("../../../extensions/telegram/src/accounts.js"); - previousTelegramToken = resolveTelegramAccount({ cfg: prevConfig, accountId }).token.trim(); - } - nextConfig = applyChannelAccountConfig({ cfg: nextConfig, channel, @@ -340,16 +334,6 @@ export async function channelsAddCommand( accountId, runtime, }); - if (channel === "telegram") { - const [{ resolveTelegramAccount }, { deleteTelegramUpdateOffset }] = await Promise.all([ - import("../../../extensions/telegram/src/accounts.js"), - import("../../../extensions/telegram/src/update-offset-store.js"), - ]); - const nextTelegramToken = resolveTelegramAccount({ cfg: nextConfig, accountId }).token.trim(); - if (previousTelegramToken !== nextTelegramToken) { - await deleteTelegramUpdateOffset({ accountId }); - } - } await writeConfigFile(nextConfig); runtime.log(`Added ${channelLabel(channel)} account "${accountId}".`); diff --git a/src/commands/channels/capabilities.test.ts b/src/commands/channels/capabilities.test.ts index 5e838cc4ec8..3a70bdb85f9 100644 --- a/src/commands/channels/capabilities.test.ts +++ b/src/commands/channels/capabilities.test.ts @@ -1,7 +1,6 @@ process.env.NO_COLOR = "1"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fetchSlackScopes } from "../../../extensions/slack/src/scopes.js"; import { getChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import { channelsCapabilitiesCommand } from "./capabilities.js"; @@ -21,10 +20,6 @@ vi.mock("../../channels/plugins/index.js", () => ({ getChannelPlugin: vi.fn(), })); -vi.mock("../../../extensions/slack/src/scopes.js", () => ({ - fetchSlackScopes: vi.fn(), -})); - const runtime = { log: (...args: unknown[]) => { logs.push(args.map(String).join(" ")); @@ -95,14 +90,22 @@ describe("channelsCapabilitiesCommand", () => { }, probe: { ok: true, bot: { name: "openclaw" }, team: { name: "team" } }, }); + plugin.status = { + ...plugin.status, + formatCapabilitiesProbe: () => [{ text: "Bot: @openclaw" }, { text: "Team: team" }], + buildCapabilitiesDiagnostics: async () => ({ + lines: [ + { text: "Bot scopes (auth.scopes): chat:write" }, + { text: "User scopes (auth.scopes): users:read" }, + ], + details: { + botScopes: { ok: true, scopes: ["chat:write"], source: "auth.scopes" }, + userScopes: { ok: true, scopes: ["users:read"], source: "auth.scopes" }, + }, + }), + }; vi.mocked(listChannelPlugins).mockReturnValue([plugin]); vi.mocked(getChannelPlugin).mockReturnValue(plugin); - vi.mocked(fetchSlackScopes).mockImplementation(async (token: string) => { - if (token === "xoxp-user") { - return { ok: true, scopes: ["users:read"], source: "auth.scopes" }; - } - return { ok: true, scopes: ["chat:write"], source: "auth.scopes" }; - }); await channelsCapabilitiesCommand({ channel: "slack" }, runtime); @@ -111,8 +114,6 @@ describe("channelsCapabilitiesCommand", () => { expect(output).toContain("User scopes"); expect(output).toContain("chat:write"); expect(output).toContain("users:read"); - expect(fetchSlackScopes).toHaveBeenCalledWith("xoxb-bot", expect.any(Number)); - expect(fetchSlackScopes).toHaveBeenCalledWith("xoxp-user", expect.any(Number)); }); it("prints Teams Graph permission hints when present", async () => { @@ -127,6 +128,15 @@ describe("channelsCapabilitiesCommand", () => { }, }, }); + plugin.status = { + ...plugin.status, + formatCapabilitiesProbe: () => [ + { text: "App: app-id" }, + { + text: "Graph roles: ChannelMessage.Read.All (channel history), Files.Read.All (files (OneDrive))", + }, + ], + }; vi.mocked(listChannelPlugins).mockReturnValue([plugin]); vi.mocked(getChannelPlugin).mockReturnValue(plugin); diff --git a/src/commands/channels/capabilities.ts b/src/commands/channels/capabilities.ts index 30f64da43d9..acd28137b30 100644 --- a/src/commands/channels/capabilities.ts +++ b/src/commands/channels/capabilities.ts @@ -1,9 +1,11 @@ -import { fetchChannelPermissionsDiscord } from "../../../extensions/discord/src/send.js"; -import { parseDiscordTarget } from "../../../extensions/discord/src/targets.js"; -import { fetchSlackScopes, type SlackScopesResult } from "../../../extensions/slack/src/scopes.js"; import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js"; import { getChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js"; -import type { ChannelCapabilities, ChannelPlugin } from "../../channels/plugins/types.js"; +import type { + ChannelCapabilities, + ChannelCapabilitiesDiagnostics, + ChannelCapabilitiesDisplayLine, + ChannelPlugin, +} from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { danger } from "../../globals.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; @@ -18,24 +20,6 @@ export type ChannelsCapabilitiesOptions = { json?: boolean; }; -type DiscordTargetSummary = { - raw?: string; - normalized?: string; - kind?: "channel" | "user"; - channelId?: string; -}; - -type DiscordPermissionsReport = { - channelId?: string; - guildId?: string; - isDm?: boolean; - channelType?: number; - permissions?: string[]; - missingRequired?: string[]; - raw?: string; - error?: string; -}; - type ChannelCapabilitiesReport = { channel: string; accountId: string; @@ -45,24 +29,7 @@ type ChannelCapabilitiesReport = { support?: ChannelCapabilities; actions?: string[]; probe?: unknown; - slackScopes?: Array<{ - tokenType: "bot" | "user"; - result: SlackScopesResult; - }>; - target?: DiscordTargetSummary; - channelPermissions?: DiscordPermissionsReport; -}; - -const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const; - -const TEAMS_GRAPH_PERMISSION_HINTS: Record = { - "ChannelMessage.Read.All": "channel history", - "Chat.Read.All": "chat history", - "Channel.ReadBasic.All": "channel list", - "Team.ReadBasic.All": "team list", - "TeamsActivity.Read.All": "teams activity", - "Sites.Read.All": "files (SharePoint)", - "Files.Read.All": "files (OneDrive)", + diagnostics?: ChannelCapabilitiesDiagnostics; }; function normalizeTimeout(raw: unknown, fallback = 10_000) { @@ -117,221 +84,35 @@ function formatSupport(capabilities?: ChannelCapabilities) { return bits.length ? bits.join(" ") : "none"; } -function summarizeDiscordTarget(raw?: string): DiscordTargetSummary | undefined { - if (!raw) { - return undefined; - } - const target = parseDiscordTarget(raw, { defaultKind: "channel" }); - if (!target) { - return { raw }; - } - if (target.kind === "channel") { - return { - raw, - normalized: target.normalized, - kind: "channel", - channelId: target.id, - }; - } - if (target.kind === "user") { - return { - raw, - normalized: target.normalized, - kind: "user", - }; - } - return { raw, normalized: target.normalized }; -} - -function formatDiscordIntents(intents?: { - messageContent?: string; - guildMembers?: string; - presence?: string; -}) { - if (!intents) { - return "unknown"; - } - return [ - `messageContent=${intents.messageContent ?? "unknown"}`, - `guildMembers=${intents.guildMembers ?? "unknown"}`, - `presence=${intents.presence ?? "unknown"}`, - ].join(" "); -} - -function formatProbeLines(channelId: string, probe: unknown): string[] { - const lines: string[] = []; +function formatGenericProbeLines(probe: unknown): ChannelCapabilitiesDisplayLine[] { if (!probe || typeof probe !== "object") { - return lines; + return []; } const probeObj = probe as Record; - - if (channelId === "discord") { - const bot = probeObj.bot as { id?: string | null; username?: string | null } | undefined; - if (bot?.username) { - const botId = bot.id ? ` (${bot.id})` : ""; - lines.push(`Bot: ${theme.accent(`@${bot.username}`)}${botId}`); - } - const app = probeObj.application as { intents?: Record } | undefined; - if (app?.intents) { - lines.push(`Intents: ${formatDiscordIntents(app.intents)}`); - } - } - - if (channelId === "telegram") { - const bot = probeObj.bot as { username?: string | null; id?: number | null } | undefined; - if (bot?.username) { - const botId = bot.id ? ` (${bot.id})` : ""; - lines.push(`Bot: ${theme.accent(`@${bot.username}`)}${botId}`); - } - const flags: string[] = []; - const canJoinGroups = (bot as { canJoinGroups?: boolean | null })?.canJoinGroups; - const canReadAll = (bot as { canReadAllGroupMessages?: boolean | null }) - ?.canReadAllGroupMessages; - const inlineQueries = (bot as { supportsInlineQueries?: boolean | null }) - ?.supportsInlineQueries; - if (typeof canJoinGroups === "boolean") { - flags.push(`joinGroups=${canJoinGroups}`); - } - if (typeof canReadAll === "boolean") { - flags.push(`readAllGroupMessages=${canReadAll}`); - } - if (typeof inlineQueries === "boolean") { - flags.push(`inlineQueries=${inlineQueries}`); - } - if (flags.length > 0) { - lines.push(`Flags: ${flags.join(" ")}`); - } - const webhook = probeObj.webhook as { url?: string | null } | undefined; - if (webhook?.url !== undefined) { - lines.push(`Webhook: ${webhook.url || "none"}`); - } - } - - if (channelId === "slack") { - const bot = probeObj.bot as { name?: string } | undefined; - const team = probeObj.team as { name?: string; id?: string } | undefined; - if (bot?.name) { - lines.push(`Bot: ${theme.accent(`@${bot.name}`)}`); - } - if (team?.name || team?.id) { - const id = team?.id ? ` (${team.id})` : ""; - lines.push(`Team: ${team?.name ?? "unknown"}${id}`); - } - } - - if (channelId === "signal") { - const version = probeObj.version as string | null | undefined; - if (version) { - lines.push(`Signal daemon: ${version}`); - } - } - - if (channelId === "msteams") { - const appId = typeof probeObj.appId === "string" ? probeObj.appId.trim() : ""; - if (appId) { - lines.push(`App: ${theme.accent(appId)}`); - } - const graph = probeObj.graph as - | { ok?: boolean; roles?: unknown; scopes?: unknown; error?: string } - | undefined; - if (graph) { - const roles = Array.isArray(graph.roles) - ? graph.roles.map((role) => String(role).trim()).filter(Boolean) - : []; - const scopes = - typeof graph.scopes === "string" - ? graph.scopes - .split(/\s+/) - .map((scope) => scope.trim()) - .filter(Boolean) - : Array.isArray(graph.scopes) - ? graph.scopes.map((scope) => String(scope).trim()).filter(Boolean) - : []; - if (graph.ok === false) { - lines.push(`Graph: ${theme.error(graph.error ?? "failed")}`); - } else if (roles.length > 0 || scopes.length > 0) { - const formatPermission = (permission: string) => { - const hint = TEAMS_GRAPH_PERMISSION_HINTS[permission]; - return hint ? `${permission} (${hint})` : permission; - }; - if (roles.length > 0) { - lines.push(`Graph roles: ${roles.map(formatPermission).join(", ")}`); - } - if (scopes.length > 0) { - lines.push(`Graph scopes: ${scopes.map(formatPermission).join(", ")}`); - } - } else if (graph.ok === true) { - lines.push("Graph: ok"); - } - } - } - const ok = typeof probeObj.ok === "boolean" ? probeObj.ok : undefined; - if (ok === true && lines.length === 0) { - lines.push("Probe: ok"); + if (ok === true) { + return [{ text: "Probe: ok" }]; } if (ok === false) { const error = typeof probeObj.error === "string" && probeObj.error ? ` (${probeObj.error})` : ""; - lines.push(`Probe: ${theme.error(`failed${error}`)}`); + return [{ text: `Probe: failed${error}`, tone: "error" }]; } - return lines; + return []; } -async function buildDiscordPermissions(params: { - account: { token?: string; accountId?: string }; - target?: string; -}): Promise<{ target?: DiscordTargetSummary; report?: DiscordPermissionsReport }> { - const target = summarizeDiscordTarget(params.target?.trim()); - if (!target) { - return {}; - } - if (target.kind !== "channel" || !target.channelId) { - return { - target, - report: { - error: "Target looks like a DM user; pass channel: to audit channel permissions.", - }, - }; - } - const token = params.account.token?.trim(); - if (!token) { - return { - target, - report: { - channelId: target.channelId, - error: "Discord bot token missing for permission audit.", - }, - }; - } - try { - const perms = await fetchChannelPermissionsDiscord(target.channelId, { - token, - accountId: params.account.accountId ?? undefined, - }); - const missing = REQUIRED_DISCORD_PERMISSIONS.filter( - (permission) => !perms.permissions.includes(permission), - ); - return { - target, - report: { - channelId: perms.channelId, - guildId: perms.guildId, - isDm: perms.isDm, - channelType: perms.channelType, - permissions: perms.permissions, - missingRequired: missing.length ? missing : [], - raw: perms.raw, - }, - }; - } catch (err) { - return { - target, - report: { - channelId: target.channelId, - error: err instanceof Error ? err.message : String(err), - }, - }; +function renderDisplayLine(line: ChannelCapabilitiesDisplayLine) { + switch (line.tone) { + case "muted": + return theme.muted(line.text); + case "success": + return theme.success(line.text); + case "warn": + return theme.warn(line.text); + case "error": + return theme.error(line.text); + default: + return line.text; } } @@ -378,41 +159,16 @@ async function resolveChannelReports(params: { } } - let slackScopes: ChannelCapabilitiesReport["slackScopes"]; - if (plugin.id === "slack" && configured && enabled) { - const botToken = (resolvedAccount as { botToken?: string }).botToken?.trim(); - const userToken = (resolvedAccount as { userToken?: string }).userToken?.trim(); - const scopeReports: NonNullable = []; - if (botToken) { - scopeReports.push({ - tokenType: "bot", - result: await fetchSlackScopes(botToken, timeoutMs), - }); - } else { - scopeReports.push({ - tokenType: "bot", - result: { ok: false, error: "Slack bot token missing." }, - }); - } - if (userToken) { - scopeReports.push({ - tokenType: "user", - result: await fetchSlackScopes(userToken, timeoutMs), - }); - } - slackScopes = scopeReports; - } - - let discordTarget: DiscordTargetSummary | undefined; - let discordPermissions: DiscordPermissionsReport | undefined; - if (plugin.id === "discord" && params.target) { - const perms = await buildDiscordPermissions({ - account: resolvedAccount as { token?: string; accountId?: string }, - target: params.target, - }); - discordTarget = perms.target; - discordPermissions = perms.report; - } + const diagnostics = + configured && enabled + ? await plugin.status?.buildCapabilitiesDiagnostics?.({ + account: resolvedAccount, + timeoutMs, + cfg, + probe, + target: params.target, + }) + : undefined; reports.push({ channel: plugin.id, @@ -425,10 +181,8 @@ async function resolveChannelReports(params: { enabled, support: plugin.capabilities, probe, - target: discordTarget, - channelPermissions: discordPermissions, actions, - slackScopes, + diagnostics, }); } return reports; @@ -451,8 +205,8 @@ export async function channelsCapabilitiesCommand( runtime.exit(1); return; } - if (rawTarget && rawChannel !== "discord") { - runtime.error(danger("--target requires --channel discord.")); + if (rawTarget && (!rawChannel || rawChannel === "all")) { + runtime.error(danger("--target requires a specific --channel.")); runtime.exit(1); return; } @@ -484,7 +238,7 @@ export async function channelsCapabilitiesCommand( cfg, timeoutMs, accountOverride, - target: rawTarget && plugin.id === "discord" ? rawTarget : undefined, + target: rawTarget || undefined, })), ); } @@ -513,39 +267,17 @@ export async function channelsCapabilitiesCommand( const enabledLabel = report.enabled === false ? "disabled" : "enabled"; lines.push(`Status: ${configuredLabel}, ${enabledLabel}`); } - const probeLines = formatProbeLines(report.channel, report.probe); + const probeLines = + getChannelPlugin(report.channel)?.status?.formatCapabilitiesProbe?.({ + probe: report.probe, + }) ?? formatGenericProbeLines(report.probe); if (probeLines.length > 0) { - lines.push(...probeLines); + lines.push(...probeLines.map(renderDisplayLine)); } else if (report.configured && report.enabled) { lines.push(theme.muted("Probe: unavailable")); } - if (report.channel === "slack" && report.slackScopes) { - for (const entry of report.slackScopes) { - const source = entry.result.source ? ` (${entry.result.source})` : ""; - const label = entry.tokenType === "user" ? "User scopes" : "Bot scopes"; - if (entry.result.ok && entry.result.scopes?.length) { - lines.push(`${label}${source}: ${entry.result.scopes.join(", ")}`); - } else if (entry.result.error) { - lines.push(`${label}: ${theme.error(entry.result.error)}`); - } - } - } - if (report.channel === "discord" && report.channelPermissions) { - const perms = report.channelPermissions; - if (perms.error) { - lines.push(`Permissions: ${theme.error(perms.error)}`); - } else { - const list = perms.permissions?.length ? perms.permissions.join(", ") : "none"; - const label = perms.channelId ? ` (${perms.channelId})` : ""; - lines.push(`Permissions${label}: ${list}`); - if (perms.missingRequired && perms.missingRequired.length > 0) { - lines.push(`${theme.warn("Missing required:")} ${perms.missingRequired.join(", ")}`); - } else { - lines.push(theme.success("Missing required: none")); - } - } - } else if (report.channel === "discord" && rawTarget && !report.channelPermissions) { - lines.push(theme.muted("Permissions: skipped (no target).")); + if (report.diagnostics?.lines?.length) { + lines.push(...report.diagnostics.lines.map(renderDisplayLine)); } lines.push(""); } diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index b7d012d0fac..1cd5fded7d3 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -118,11 +118,6 @@ export async function channelsRemoveCommand( accountId: resolvedAccountId, runtime, }); - if (channel === "telegram") { - const { deleteTelegramUpdateOffset } = - await import("../../../extensions/telegram/src/update-offset-store.js"); - await deleteTelegramUpdateOffset({ accountId: resolvedAccountId }); - } } else { if (!plugin.config.setAccountEnabled) { runtime.error(`Channel ${channel} does not support disable.`); From 9cd9c7a4884177ecfbd4e040e2487701a2d1bb66 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:21:52 +0000 Subject: [PATCH 06/23] refactor: split slack block action handling --- .../events/interactions.block-actions.ts | 773 ++++++++++++++++++ .../slack/src/monitor/events/interactions.ts | 607 +------------- 2 files changed, 781 insertions(+), 599 deletions(-) create mode 100644 extensions/slack/src/monitor/events/interactions.block-actions.ts diff --git a/extensions/slack/src/monitor/events/interactions.block-actions.ts b/extensions/slack/src/monitor/events/interactions.block-actions.ts new file mode 100644 index 00000000000..1f54df45a5d --- /dev/null +++ b/extensions/slack/src/monitor/events/interactions.block-actions.ts @@ -0,0 +1,773 @@ +import type { SlackActionMiddlewareArgs } from "@slack/bolt"; +import type { Block, KnownBlock } from "@slack/web-api"; +import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js"; +import { + buildPluginBindingResolvedText, + parsePluginBindingApprovalCustomId, + resolvePluginConversationBindingApproval, +} from "../../../../../src/plugins/conversation-binding.js"; +import { dispatchPluginInteractiveHandler } from "../../../../../src/plugins/interactive.js"; +import { SLACK_REPLY_BUTTON_ACTION_ID, SLACK_REPLY_SELECT_ACTION_ID } from "../../blocks-render.js"; +import { authorizeSlackSystemEventSender } from "../auth.js"; +import type { SlackMonitorContext } from "../context.js"; +import { escapeSlackMrkdwn } from "../mrkdwn.js"; + +type InteractionMessageBlock = { + type?: string; + block_id?: string; + elements?: Array<{ action_id?: string }>; +}; + +type SelectOption = { + value?: string; + text?: { text?: string }; +}; + +type InteractionSelectionFields = { + blockId?: string; + callbackId?: string; + value?: string; + inputKind?: "number" | "text" | "url" | "email" | "rich_text"; + inputValue?: string; + inputNumber?: number; + inputEmail?: string; + inputUrl?: string; + richTextValue?: unknown; + richTextPreview?: string; + selectedValues?: string[]; + selectedUsers?: string[]; + selectedChannels?: string[]; + selectedConversations?: string[]; + selectedLabels?: string[]; + selectedDate?: string; + selectedTime?: string; + selectedDateTime?: number; + actionType?: string; + viewId?: string; + privateMetadata?: string; + viewHash?: string; + inputs?: unknown[]; + isCleared?: boolean; + routedChannelType?: string; + routedChannelId?: string; +}; + +export type InteractionSummary = InteractionSelectionFields & { + interactionType?: "block_action" | "view_submission" | "view_closed"; + actionId: string; + userId?: string; + teamId?: string; + triggerId?: string; + responseUrl?: string; + workflowTriggerUrl?: string; + workflowId?: string; + channelId?: string; + messageTs?: string; + threadTs?: string; +}; + +type SlackActionSummary = Omit; + +type SlackBlockActionBody = { + user?: { id?: string }; + team?: { id?: string }; + trigger_id?: string; + response_url?: string; + channel?: { id?: string }; + container?: { channel_id?: string; message_ts?: string; thread_ts?: string }; + message?: { ts?: string; text?: string; blocks?: unknown[] }; +}; + +type SlackBlockActionRespond = NonNullable; + +type ParsedSlackBlockAction = { + typedBody: SlackBlockActionBody; + typedAction: Record; + typedActionWithText: { + action_id?: string; + block_id?: string; + type?: string; + text?: { text?: string }; + }; + actionId: string; + blockId?: string; + userId: string; + channelId?: string; + messageTs?: string; + threadTs?: string; + actionSummary: SlackActionSummary; +}; + +function readOptionValues(options: unknown): string[] | undefined { + if (!Array.isArray(options)) { + return undefined; + } + const values = options + .map((option) => (option && typeof option === "object" ? (option as SelectOption).value : null)) + .filter((value): value is string => typeof value === "string" && value.trim().length > 0); + return values.length > 0 ? values : undefined; +} + +function readOptionLabels(options: unknown): string[] | undefined { + if (!Array.isArray(options)) { + return undefined; + } + const labels = options + .map((option) => + option && typeof option === "object" ? ((option as SelectOption).text?.text ?? null) : null, + ) + .filter((label): label is string => typeof label === "string" && label.trim().length > 0); + return labels.length > 0 ? labels : undefined; +} + +function uniqueNonEmptyStrings(values: string[]): string[] { + const unique: string[] = []; + const seen = new Set(); + for (const entry of values) { + if (typeof entry !== "string") { + continue; + } + const trimmed = entry.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + unique.push(trimmed); + } + return unique; +} + +function collectRichTextFragments(value: unknown, out: string[]): void { + if (!value || typeof value !== "object") { + return; + } + const typed = value as { text?: unknown; elements?: unknown }; + if (typeof typed.text === "string" && typed.text.trim().length > 0) { + out.push(typed.text.trim()); + } + if (Array.isArray(typed.elements)) { + for (const child of typed.elements) { + collectRichTextFragments(child, out); + } + } +} + +function summarizeRichTextPreview(value: unknown): string | undefined { + const fragments: string[] = []; + collectRichTextFragments(value, fragments); + if (fragments.length === 0) { + return undefined; + } + const joined = fragments.join(" ").replace(/\s+/g, " ").trim(); + if (!joined) { + return undefined; + } + const max = 120; + return joined.length <= max ? joined : `${joined.slice(0, max - 1)}…`; +} + +function readInteractionAction(raw: unknown) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return undefined; + } + return raw as Record; +} + +export function summarizeAction(action: Record): SlackActionSummary { + const typed = action as { + type?: string; + selected_option?: SelectOption; + selected_options?: SelectOption[]; + selected_user?: string; + selected_users?: string[]; + selected_channel?: string; + selected_channels?: string[]; + selected_conversation?: string; + selected_conversations?: string[]; + selected_date?: string; + selected_time?: string; + selected_date_time?: number; + value?: string; + rich_text_value?: unknown; + workflow?: { + trigger_url?: string; + workflow_id?: string; + }; + }; + const actionType = typed.type; + const selectedUsers = uniqueNonEmptyStrings([ + ...(typed.selected_user ? [typed.selected_user] : []), + ...(Array.isArray(typed.selected_users) ? typed.selected_users : []), + ]); + const selectedChannels = uniqueNonEmptyStrings([ + ...(typed.selected_channel ? [typed.selected_channel] : []), + ...(Array.isArray(typed.selected_channels) ? typed.selected_channels : []), + ]); + const selectedConversations = uniqueNonEmptyStrings([ + ...(typed.selected_conversation ? [typed.selected_conversation] : []), + ...(Array.isArray(typed.selected_conversations) ? typed.selected_conversations : []), + ]); + const selectedValues = uniqueNonEmptyStrings([ + ...(typed.selected_option?.value ? [typed.selected_option.value] : []), + ...(readOptionValues(typed.selected_options) ?? []), + ...selectedUsers, + ...selectedChannels, + ...selectedConversations, + ]); + const selectedLabels = uniqueNonEmptyStrings([ + ...(typed.selected_option?.text?.text ? [typed.selected_option.text.text] : []), + ...(readOptionLabels(typed.selected_options) ?? []), + ]); + const inputValue = typeof typed.value === "string" ? typed.value : undefined; + const inputNumber = + actionType === "number_input" && inputValue != null ? Number.parseFloat(inputValue) : undefined; + const parsedNumber = Number.isFinite(inputNumber) ? inputNumber : undefined; + const inputEmail = + actionType === "email_text_input" && inputValue?.includes("@") ? inputValue : undefined; + let inputUrl: string | undefined; + if (actionType === "url_text_input" && inputValue) { + try { + inputUrl = new URL(inputValue).toString(); + } catch { + inputUrl = undefined; + } + } + const richTextValue = actionType === "rich_text_input" ? typed.rich_text_value : undefined; + const richTextPreview = summarizeRichTextPreview(richTextValue); + const inputKind = + actionType === "number_input" + ? "number" + : actionType === "email_text_input" + ? "email" + : actionType === "url_text_input" + ? "url" + : actionType === "rich_text_input" + ? "rich_text" + : inputValue != null + ? "text" + : undefined; + + return { + actionType, + inputKind, + value: typed.value, + selectedValues: selectedValues.length > 0 ? selectedValues : undefined, + selectedUsers: selectedUsers.length > 0 ? selectedUsers : undefined, + selectedChannels: selectedChannels.length > 0 ? selectedChannels : undefined, + selectedConversations: selectedConversations.length > 0 ? selectedConversations : undefined, + selectedLabels: selectedLabels.length > 0 ? selectedLabels : undefined, + selectedDate: typed.selected_date, + selectedTime: typed.selected_time, + selectedDateTime: + typeof typed.selected_date_time === "number" ? typed.selected_date_time : undefined, + inputValue, + inputNumber: parsedNumber, + inputEmail, + inputUrl, + richTextValue, + richTextPreview, + workflowTriggerUrl: typed.workflow?.trigger_url, + workflowId: typed.workflow?.workflow_id, + }; +} + +function isBulkActionsBlock(block: InteractionMessageBlock): boolean { + return ( + block.type === "actions" && + Array.isArray(block.elements) && + block.elements.length > 0 && + block.elements.every((el) => typeof el.action_id === "string" && el.action_id.includes("_all_")) + ); +} + +function formatInteractionSelectionLabel(params: { + actionId: string; + summary: SlackActionSummary; + buttonText?: string; +}): string { + if (params.summary.actionType === "button" && params.buttonText?.trim()) { + return params.buttonText.trim(); + } + if (params.summary.selectedLabels?.length) { + if (params.summary.selectedLabels.length <= 3) { + return params.summary.selectedLabels.join(", "); + } + return `${params.summary.selectedLabels.slice(0, 3).join(", ")} +${ + params.summary.selectedLabels.length - 3 + }`; + } + if (params.summary.selectedValues?.length) { + if (params.summary.selectedValues.length <= 3) { + return params.summary.selectedValues.join(", "); + } + return `${params.summary.selectedValues.slice(0, 3).join(", ")} +${ + params.summary.selectedValues.length - 3 + }`; + } + if (params.summary.selectedDate) { + return params.summary.selectedDate; + } + if (params.summary.selectedTime) { + return params.summary.selectedTime; + } + if (typeof params.summary.selectedDateTime === "number") { + return new Date(params.summary.selectedDateTime * 1000).toISOString(); + } + if (params.summary.richTextPreview) { + return params.summary.richTextPreview; + } + if (params.summary.value?.trim()) { + return params.summary.value.trim(); + } + return params.actionId; +} + +function formatInteractionConfirmationText(params: { + selectedLabel: string; + userId?: string; +}): string { + const actor = params.userId?.trim() ? ` by <@${params.userId.trim()}>` : ""; + return `:white_check_mark: *${escapeSlackMrkdwn(params.selectedLabel)}* selected${actor}`; +} + +function buildSlackPluginInteractionData(params: { + actionId: string; + summary: SlackActionSummary; +}): string | null { + const actionId = params.actionId.trim(); + if (!actionId) { + return null; + } + const payload = + params.summary.value?.trim() || + params.summary.selectedValues?.map((value) => value.trim()).find(Boolean) || + ""; + if (actionId === SLACK_REPLY_BUTTON_ACTION_ID || actionId === SLACK_REPLY_SELECT_ACTION_ID) { + return payload || null; + } + return payload ? `${actionId}:${payload}` : actionId; +} + +function buildSlackPluginInteractionId(params: { + userId?: string; + channelId?: string; + messageTs?: string; + triggerId?: string; + actionId: string; + summary: SlackActionSummary; +}): string { + const primaryValue = + params.summary.value?.trim() || + params.summary.selectedValues?.map((value) => value.trim()).find(Boolean) || + ""; + return [ + params.userId?.trim() || "", + params.channelId?.trim() || "", + params.messageTs?.trim() || "", + params.triggerId?.trim() || "", + params.actionId.trim(), + primaryValue, + ].join(":"); +} + +function parseSlackBlockAction(params: { + body: unknown; + action: unknown; + log?: (message: string) => void; +}): ParsedSlackBlockAction | null { + const typedBody = params.body as SlackBlockActionBody; + const typedAction = readInteractionAction(params.action); + if (!typedAction) { + params.log?.( + `slack:interaction malformed action payload channel=${typedBody.channel?.id ?? typedBody.container?.channel_id ?? "unknown"} user=${ + typedBody.user?.id ?? "unknown" + }`, + ); + return null; + } + const typedActionWithText = typedAction as { + action_id?: string; + block_id?: string; + type?: string; + text?: { text?: string }; + }; + return { + typedBody, + typedAction, + typedActionWithText, + actionId: + typeof typedActionWithText.action_id === "string" ? typedActionWithText.action_id : "unknown", + blockId: typedActionWithText.block_id, + userId: typedBody.user?.id ?? "unknown", + channelId: typedBody.channel?.id ?? typedBody.container?.channel_id, + messageTs: typedBody.message?.ts ?? typedBody.container?.message_ts, + threadTs: typedBody.container?.thread_ts, + actionSummary: summarizeAction(typedAction), + }; +} + +async function respondEphemeral( + respond: SlackBlockActionRespond | undefined, + text: string, +): Promise { + if (!respond) { + return; + } + try { + await respond({ + text, + response_type: "ephemeral", + }); + } catch { + // Best-effort feedback only. + } +} + +async function updateSlackInteractionMessage(params: { + ctx: SlackMonitorContext; + channelId?: string; + messageTs?: string; + text: string; + blocks?: (Block | KnownBlock)[]; +}): Promise { + if (!params.channelId || !params.messageTs) { + return; + } + await params.ctx.app.client.chat.update({ + channel: params.channelId, + ts: params.messageTs, + text: params.text, + ...(params.blocks ? { blocks: params.blocks } : {}), + }); +} + +async function authorizeSlackBlockAction(params: { + ctx: SlackMonitorContext; + parsed: ParsedSlackBlockAction; + respond?: SlackBlockActionRespond; +}): Promise< + | { + allowed: true; + channelType?: "im" | "mpim" | "channel" | "group"; + } + | { allowed: false } +> { + const auth = await authorizeSlackSystemEventSender({ + ctx: params.ctx, + senderId: params.parsed.userId, + channelId: params.parsed.channelId, + }); + if (auth.allowed) { + return auth; + } + params.ctx.runtime.log?.( + `slack:interaction drop action=${params.parsed.actionId} user=${params.parsed.userId} channel=${params.parsed.channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`, + ); + await respondEphemeral(params.respond, "You are not authorized to use this control."); + return { allowed: false }; +} + +async function handleSlackPluginBindingApproval(params: { + ctx: SlackMonitorContext; + parsed: ParsedSlackBlockAction; + pluginInteractionData: string; + respond?: SlackBlockActionRespond; +}): Promise { + const pluginBindingApproval = parsePluginBindingApprovalCustomId(params.pluginInteractionData); + if (!pluginBindingApproval) { + return false; + } + const resolved = await resolvePluginConversationBindingApproval({ + approvalId: pluginBindingApproval.approvalId, + decision: pluginBindingApproval.decision, + senderId: params.parsed.userId, + }); + try { + await updateSlackInteractionMessage({ + ctx: params.ctx, + channelId: params.parsed.channelId, + messageTs: params.parsed.messageTs, + text: params.parsed.typedBody.message?.text ?? "", + blocks: [], + }); + } catch { + // Best-effort cleanup only; continue with follow-up feedback. + } + await respondEphemeral(params.respond, buildPluginBindingResolvedText(resolved)); + return true; +} + +async function dispatchSlackPluginInteraction(params: { + ctx: SlackMonitorContext; + parsed: ParsedSlackBlockAction; + pluginInteractionData: string; + auth: { isAuthorizedSender: boolean }; + respond?: SlackBlockActionRespond; +}): Promise { + const pluginInteractionId = buildSlackPluginInteractionId({ + userId: params.parsed.userId, + channelId: params.parsed.channelId, + messageTs: params.parsed.messageTs, + triggerId: params.parsed.typedBody.trigger_id, + actionId: params.parsed.actionId, + summary: params.parsed.actionSummary, + }); + if ( + await handleSlackPluginBindingApproval({ + ctx: params.ctx, + parsed: params.parsed, + pluginInteractionData: params.pluginInteractionData, + respond: params.respond, + }) + ) { + return true; + } + const pluginResult = await dispatchPluginInteractiveHandler({ + channel: "slack", + data: params.pluginInteractionData, + interactionId: pluginInteractionId, + ctx: { + accountId: params.ctx.accountId, + interactionId: pluginInteractionId, + conversationId: params.parsed.channelId ?? "", + parentConversationId: undefined, + threadId: params.parsed.threadTs, + senderId: params.parsed.userId, + senderUsername: undefined, + auth: params.auth, + interaction: { + kind: params.parsed.actionSummary.actionType === "button" ? "button" : "select", + actionId: params.parsed.actionId, + blockId: params.parsed.blockId, + messageTs: params.parsed.messageTs, + threadTs: params.parsed.threadTs, + value: params.parsed.actionSummary.value, + selectedValues: params.parsed.actionSummary.selectedValues, + selectedLabels: params.parsed.actionSummary.selectedLabels, + triggerId: params.parsed.typedBody.trigger_id, + responseUrl: params.parsed.typedBody.response_url, + }, + }, + respond: { + acknowledge: async () => {}, + reply: async ({ text, responseType }) => { + if (!text) { + return; + } + await params.respond?.({ + text, + response_type: responseType ?? "ephemeral", + }); + }, + followUp: async ({ text, responseType }) => { + if (!text) { + return; + } + await params.respond?.({ + text, + response_type: responseType ?? "ephemeral", + }); + }, + editMessage: async ({ text, blocks }) => { + await updateSlackInteractionMessage({ + ctx: params.ctx, + channelId: params.parsed.channelId, + messageTs: params.parsed.messageTs, + text: text ?? params.parsed.typedBody.message?.text ?? "", + blocks: Array.isArray(blocks) ? (blocks as (Block | KnownBlock)[]) : undefined, + }); + }, + }, + }); + return pluginResult.matched && pluginResult.handled; +} + +function enqueueSlackBlockActionEvent(params: { + ctx: SlackMonitorContext; + parsed: ParsedSlackBlockAction; + auth: { channelType?: "im" | "mpim" | "channel" | "group" }; + formatSystemEvent: (payload: Record) => string; +}): void { + const eventPayload: InteractionSummary = { + interactionType: "block_action", + actionId: params.parsed.actionId, + blockId: params.parsed.blockId, + ...params.parsed.actionSummary, + userId: params.parsed.userId, + teamId: params.parsed.typedBody.team?.id, + triggerId: params.parsed.typedBody.trigger_id, + responseUrl: params.parsed.typedBody.response_url, + channelId: params.parsed.channelId, + messageTs: params.parsed.messageTs, + threadTs: params.parsed.threadTs, + }; + params.ctx.runtime.log?.( + `slack:interaction action=${params.parsed.actionId} type=${params.parsed.actionSummary.actionType ?? "unknown"} user=${params.parsed.userId} channel=${params.parsed.channelId}`, + ); + const sessionKey = params.ctx.resolveSlackSystemEventSessionKey({ + channelId: params.parsed.channelId, + channelType: params.auth.channelType, + senderId: params.parsed.userId, + }); + const contextParts = [ + "slack:interaction", + params.parsed.channelId, + params.parsed.messageTs, + params.parsed.actionId, + ].filter(Boolean); + enqueueSystemEvent(params.formatSystemEvent(eventPayload), { + sessionKey, + contextKey: contextParts.join(":"), + }); +} + +function buildSlackConfirmationBlocks(params: { + parsed: ParsedSlackBlockAction; + originalBlocks: unknown[]; +}): (Block | KnownBlock)[] { + const selectedLabel = formatInteractionSelectionLabel({ + actionId: params.parsed.actionId, + summary: params.parsed.actionSummary, + buttonText: params.parsed.typedActionWithText.text?.text, + }); + let updatedBlocks = params.originalBlocks.map((block) => { + const typedBlock = block as InteractionMessageBlock; + if (typedBlock.type === "actions" && typedBlock.block_id === params.parsed.blockId) { + return { + type: "context", + elements: [ + { + type: "mrkdwn", + text: formatInteractionConfirmationText({ + selectedLabel, + userId: params.parsed.userId, + }), + }, + ], + }; + } + return block; + }); + const hasRemainingIndividualActionRows = updatedBlocks.some((block) => { + const typedBlock = block as InteractionMessageBlock; + return typedBlock.type === "actions" && !isBulkActionsBlock(typedBlock); + }); + if (!hasRemainingIndividualActionRows) { + updatedBlocks = updatedBlocks.filter((block, index) => { + const typedBlock = block as InteractionMessageBlock; + if (isBulkActionsBlock(typedBlock)) { + return false; + } + if (typedBlock.type !== "divider") { + return true; + } + const next = updatedBlocks[index + 1] as InteractionMessageBlock | undefined; + return !next || !isBulkActionsBlock(next); + }); + } + return updatedBlocks as (Block | KnownBlock)[]; +} + +async function updateSlackLegacyBlockAction(params: { + ctx: SlackMonitorContext; + parsed: ParsedSlackBlockAction; + respond?: SlackBlockActionRespond; +}): Promise { + const originalBlocks = params.parsed.typedBody.message?.blocks; + if ( + !Array.isArray(originalBlocks) || + !params.parsed.channelId || + !params.parsed.messageTs || + !params.parsed.blockId + ) { + return; + } + try { + await updateSlackInteractionMessage({ + ctx: params.ctx, + channelId: params.parsed.channelId, + messageTs: params.parsed.messageTs, + text: params.parsed.typedBody.message?.text ?? "", + blocks: buildSlackConfirmationBlocks({ + parsed: params.parsed, + originalBlocks, + }), + }); + } catch { + await respondEphemeral(params.respond, `Button "${params.parsed.actionId}" clicked!`); + } +} + +async function handleSlackBlockAction(params: { + ctx: SlackMonitorContext; + args: SlackActionMiddlewareArgs; + formatSystemEvent: (payload: Record) => string; +}): Promise { + const { ack, body, action, respond } = params.args; + await ack(); + if (params.ctx.shouldDropMismatchedSlackEvent?.(body)) { + params.ctx.runtime.log?.("slack:interaction drop block action payload (mismatched app/team)"); + return; + } + const parsed = parseSlackBlockAction({ + body, + action, + log: params.ctx.runtime.log, + }); + if (!parsed) { + return; + } + const auth = await authorizeSlackBlockAction({ + ctx: params.ctx, + parsed, + respond, + }); + if (!auth.allowed) { + return; + } + const pluginInteractionData = buildSlackPluginInteractionData({ + actionId: parsed.actionId, + summary: parsed.actionSummary, + }); + if (pluginInteractionData) { + const handled = await dispatchSlackPluginInteraction({ + ctx: params.ctx, + parsed, + pluginInteractionData, + auth: { + isAuthorizedSender: true, + }, + respond, + }); + if (handled) { + return; + } + } + enqueueSlackBlockActionEvent({ + ctx: params.ctx, + parsed, + auth, + formatSystemEvent: params.formatSystemEvent, + }); + await updateSlackLegacyBlockAction({ + ctx: params.ctx, + parsed, + respond, + }); +} + +export function registerSlackBlockActionHandler(params: { + ctx: SlackMonitorContext; + formatSystemEvent: (payload: Record) => string; +}): void { + if (typeof params.ctx.app.action !== "function") { + return; + } + params.ctx.app.action(/.+/, async (args: SlackActionMiddlewareArgs) => { + await handleSlackBlockAction({ + ctx: params.ctx, + args, + formatSystemEvent: params.formatSystemEvent, + }); + }); +} diff --git a/extensions/slack/src/monitor/events/interactions.ts b/extensions/slack/src/monitor/events/interactions.ts index 1ebb55d090e..384498ac5fe 100644 --- a/extensions/slack/src/monitor/events/interactions.ts +++ b/extensions/slack/src/monitor/events/interactions.ts @@ -1,17 +1,10 @@ -import type { SlackActionMiddlewareArgs } from "@slack/bolt"; -import type { Block, KnownBlock } from "@slack/web-api"; -import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js"; -import { - buildPluginBindingResolvedText, - parsePluginBindingApprovalCustomId, - resolvePluginConversationBindingApproval, -} from "../../../../../src/plugins/conversation-binding.js"; -import { dispatchPluginInteractiveHandler } from "../../../../../src/plugins/interactive.js"; -import { SLACK_REPLY_BUTTON_ACTION_ID, SLACK_REPLY_SELECT_ACTION_ID } from "../../blocks-render.js"; import { truncateSlackText } from "../../truncate.js"; -import { authorizeSlackSystemEventSender } from "../auth.js"; import type { SlackMonitorContext } from "../context.js"; -import { escapeSlackMrkdwn } from "../mrkdwn.js"; +import { + registerSlackBlockActionHandler, + summarizeAction, + type InteractionSummary, +} from "./interactions.block-actions.js"; import { registerModalLifecycleHandler, type ModalInputSummary, @@ -34,33 +27,6 @@ const SLACK_INTERACTION_REDACTED_KEYS = new Set([ "viewHash", ]); -type InteractionMessageBlock = { - type?: string; - block_id?: string; - elements?: Array<{ action_id?: string }>; -}; - -type SelectOption = { - value?: string; - text?: { text?: string }; -}; - -type InteractionSelectionFields = Partial; - -type InteractionSummary = InteractionSelectionFields & { - interactionType?: "block_action" | "view_submission" | "view_closed"; - actionId: string; - userId?: string; - teamId?: string; - triggerId?: string; - responseUrl?: string; - workflowTriggerUrl?: string; - workflowId?: string; - channelId?: string; - messageTs?: string; - threadTs?: string; -}; - function sanitizeSlackInteractionPayloadValue(value: unknown, key?: string): unknown { if (value === undefined) { return undefined; @@ -189,281 +155,6 @@ function formatSlackInteractionSystemEvent(payload: Record): st }); } -function readOptionValues(options: unknown): string[] | undefined { - if (!Array.isArray(options)) { - return undefined; - } - const values = options - .map((option) => (option && typeof option === "object" ? (option as SelectOption).value : null)) - .filter((value): value is string => typeof value === "string" && value.trim().length > 0); - return values.length > 0 ? values : undefined; -} - -function readOptionLabels(options: unknown): string[] | undefined { - if (!Array.isArray(options)) { - return undefined; - } - const labels = options - .map((option) => - option && typeof option === "object" ? ((option as SelectOption).text?.text ?? null) : null, - ) - .filter((label): label is string => typeof label === "string" && label.trim().length > 0); - return labels.length > 0 ? labels : undefined; -} - -function uniqueNonEmptyStrings(values: string[]): string[] { - const unique: string[] = []; - const seen = new Set(); - for (const entry of values) { - if (typeof entry !== "string") { - continue; - } - const trimmed = entry.trim(); - if (!trimmed || seen.has(trimmed)) { - continue; - } - seen.add(trimmed); - unique.push(trimmed); - } - return unique; -} - -function collectRichTextFragments(value: unknown, out: string[]): void { - if (!value || typeof value !== "object") { - return; - } - const typed = value as { text?: unknown; elements?: unknown }; - if (typeof typed.text === "string" && typed.text.trim().length > 0) { - out.push(typed.text.trim()); - } - if (Array.isArray(typed.elements)) { - for (const child of typed.elements) { - collectRichTextFragments(child, out); - } - } -} - -function summarizeRichTextPreview(value: unknown): string | undefined { - const fragments: string[] = []; - collectRichTextFragments(value, fragments); - if (fragments.length === 0) { - return undefined; - } - const joined = fragments.join(" ").replace(/\s+/g, " ").trim(); - if (!joined) { - return undefined; - } - const max = 120; - return joined.length <= max ? joined : `${joined.slice(0, max - 1)}…`; -} - -function readInteractionAction(raw: unknown) { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - return undefined; - } - return raw as Record; -} - -function summarizeAction( - action: Record, -): Omit { - const typed = action as { - type?: string; - selected_option?: SelectOption; - selected_options?: SelectOption[]; - selected_user?: string; - selected_users?: string[]; - selected_channel?: string; - selected_channels?: string[]; - selected_conversation?: string; - selected_conversations?: string[]; - selected_date?: string; - selected_time?: string; - selected_date_time?: number; - value?: string; - rich_text_value?: unknown; - workflow?: { - trigger_url?: string; - workflow_id?: string; - }; - }; - const actionType = typed.type; - const selectedUsers = uniqueNonEmptyStrings([ - ...(typed.selected_user ? [typed.selected_user] : []), - ...(Array.isArray(typed.selected_users) ? typed.selected_users : []), - ]); - const selectedChannels = uniqueNonEmptyStrings([ - ...(typed.selected_channel ? [typed.selected_channel] : []), - ...(Array.isArray(typed.selected_channels) ? typed.selected_channels : []), - ]); - const selectedConversations = uniqueNonEmptyStrings([ - ...(typed.selected_conversation ? [typed.selected_conversation] : []), - ...(Array.isArray(typed.selected_conversations) ? typed.selected_conversations : []), - ]); - const selectedValues = uniqueNonEmptyStrings([ - ...(typed.selected_option?.value ? [typed.selected_option.value] : []), - ...(readOptionValues(typed.selected_options) ?? []), - ...selectedUsers, - ...selectedChannels, - ...selectedConversations, - ]); - const selectedLabels = uniqueNonEmptyStrings([ - ...(typed.selected_option?.text?.text ? [typed.selected_option.text.text] : []), - ...(readOptionLabels(typed.selected_options) ?? []), - ]); - const inputValue = typeof typed.value === "string" ? typed.value : undefined; - const inputNumber = - actionType === "number_input" && inputValue != null ? Number.parseFloat(inputValue) : undefined; - const parsedNumber = Number.isFinite(inputNumber) ? inputNumber : undefined; - const inputEmail = - actionType === "email_text_input" && inputValue?.includes("@") ? inputValue : undefined; - let inputUrl: string | undefined; - if (actionType === "url_text_input" && inputValue) { - try { - // Normalize to a canonical URL string so downstream handlers do not need to reparse. - inputUrl = new URL(inputValue).toString(); - } catch { - inputUrl = undefined; - } - } - const richTextValue = actionType === "rich_text_input" ? typed.rich_text_value : undefined; - const richTextPreview = summarizeRichTextPreview(richTextValue); - const inputKind = - actionType === "number_input" - ? "number" - : actionType === "email_text_input" - ? "email" - : actionType === "url_text_input" - ? "url" - : actionType === "rich_text_input" - ? "rich_text" - : inputValue != null - ? "text" - : undefined; - - return { - actionType, - inputKind, - value: typed.value, - selectedValues: selectedValues.length > 0 ? selectedValues : undefined, - selectedUsers: selectedUsers.length > 0 ? selectedUsers : undefined, - selectedChannels: selectedChannels.length > 0 ? selectedChannels : undefined, - selectedConversations: selectedConversations.length > 0 ? selectedConversations : undefined, - selectedLabels: selectedLabels.length > 0 ? selectedLabels : undefined, - selectedDate: typed.selected_date, - selectedTime: typed.selected_time, - selectedDateTime: - typeof typed.selected_date_time === "number" ? typed.selected_date_time : undefined, - inputValue, - inputNumber: parsedNumber, - inputEmail, - inputUrl, - richTextValue, - richTextPreview, - workflowTriggerUrl: typed.workflow?.trigger_url, - workflowId: typed.workflow?.workflow_id, - }; -} - -function isBulkActionsBlock(block: InteractionMessageBlock): boolean { - return ( - block.type === "actions" && - Array.isArray(block.elements) && - block.elements.length > 0 && - block.elements.every((el) => typeof el.action_id === "string" && el.action_id.includes("_all_")) - ); -} - -function formatInteractionSelectionLabel(params: { - actionId: string; - summary: Omit; - buttonText?: string; -}): string { - if (params.summary.actionType === "button" && params.buttonText?.trim()) { - return params.buttonText.trim(); - } - if (params.summary.selectedLabels?.length) { - if (params.summary.selectedLabels.length <= 3) { - return params.summary.selectedLabels.join(", "); - } - return `${params.summary.selectedLabels.slice(0, 3).join(", ")} +${ - params.summary.selectedLabels.length - 3 - }`; - } - if (params.summary.selectedValues?.length) { - if (params.summary.selectedValues.length <= 3) { - return params.summary.selectedValues.join(", "); - } - return `${params.summary.selectedValues.slice(0, 3).join(", ")} +${ - params.summary.selectedValues.length - 3 - }`; - } - if (params.summary.selectedDate) { - return params.summary.selectedDate; - } - if (params.summary.selectedTime) { - return params.summary.selectedTime; - } - if (typeof params.summary.selectedDateTime === "number") { - return new Date(params.summary.selectedDateTime * 1000).toISOString(); - } - if (params.summary.richTextPreview) { - return params.summary.richTextPreview; - } - if (params.summary.value?.trim()) { - return params.summary.value.trim(); - } - return params.actionId; -} - -function formatInteractionConfirmationText(params: { - selectedLabel: string; - userId?: string; -}): string { - const actor = params.userId?.trim() ? ` by <@${params.userId.trim()}>` : ""; - return `:white_check_mark: *${escapeSlackMrkdwn(params.selectedLabel)}* selected${actor}`; -} - -function buildSlackPluginInteractionData(params: { - actionId: string; - summary: Omit; -}): string | null { - const actionId = params.actionId.trim(); - if (!actionId) { - return null; - } - const payload = - params.summary.value?.trim() || - params.summary.selectedValues?.map((value) => value.trim()).find(Boolean) || - ""; - if (actionId === SLACK_REPLY_BUTTON_ACTION_ID || actionId === SLACK_REPLY_SELECT_ACTION_ID) { - return payload || null; - } - return payload ? `${actionId}:${payload}` : actionId; -} - -function buildSlackPluginInteractionId(params: { - userId?: string; - channelId?: string; - messageTs?: string; - triggerId?: string; - actionId: string; - summary: Omit; -}): string { - const primaryValue = - params.summary.value?.trim() || - params.summary.selectedValues?.map((value) => value.trim()).find(Boolean) || - ""; - return [ - params.userId?.trim() || "", - params.channelId?.trim() || "", - params.messageTs?.trim() || "", - params.triggerId?.trim() || "", - params.actionId.trim(), - primaryValue, - ].join(":"); -} - function summarizeViewState(values: unknown): ModalInputSummary[] { if (!values || typeof values !== "object") { return []; @@ -490,291 +181,9 @@ function summarizeViewState(values: unknown): ModalInputSummary[] { export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContext }) { const { ctx } = params; - if (typeof ctx.app.action !== "function") { - return; - } - - // Handle Block Kit actions for this Slack app, including legacy/custom - // action_ids that plugin handlers map into shared interactive namespaces. - ctx.app.action(/.+/, async (args: SlackActionMiddlewareArgs) => { - const { ack, body, action, respond } = args; - const typedBody = body as unknown as { - user?: { id?: string }; - team?: { id?: string }; - trigger_id?: string; - response_url?: string; - channel?: { id?: string }; - container?: { channel_id?: string; message_ts?: string; thread_ts?: string }; - message?: { ts?: string; text?: string; blocks?: unknown[] }; - }; - - // Acknowledge the action immediately to prevent the warning icon - await ack(); - if (ctx.shouldDropMismatchedSlackEvent?.(body)) { - ctx.runtime.log?.("slack:interaction drop block action payload (mismatched app/team)"); - return; - } - - // Extract action details using proper Bolt types - const typedAction = readInteractionAction(action); - if (!typedAction) { - ctx.runtime.log?.( - `slack:interaction malformed action payload channel=${typedBody.channel?.id ?? typedBody.container?.channel_id ?? "unknown"} user=${ - typedBody.user?.id ?? "unknown" - }`, - ); - return; - } - const typedActionWithText = typedAction as { - action_id?: string; - block_id?: string; - type?: string; - text?: { text?: string }; - }; - const actionId = - typeof typedActionWithText.action_id === "string" ? typedActionWithText.action_id : "unknown"; - const blockId = typedActionWithText.block_id; - const userId = typedBody.user?.id ?? "unknown"; - const channelId = typedBody.channel?.id ?? typedBody.container?.channel_id; - const messageTs = typedBody.message?.ts ?? typedBody.container?.message_ts; - const threadTs = typedBody.container?.thread_ts; - const auth = await authorizeSlackSystemEventSender({ - ctx, - senderId: userId, - channelId, - }); - if (!auth.allowed) { - ctx.runtime.log?.( - `slack:interaction drop action=${actionId} user=${userId} channel=${channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`, - ); - if (respond) { - try { - await respond({ - text: "You are not authorized to use this control.", - response_type: "ephemeral", - }); - } catch { - // Best-effort feedback only. - } - } - return; - } - const actionSummary = summarizeAction(typedAction); - const pluginInteractionData = buildSlackPluginInteractionData({ - actionId, - summary: actionSummary, - }); - if (pluginInteractionData) { - const pluginInteractionId = buildSlackPluginInteractionId({ - userId, - channelId, - messageTs, - triggerId: typedBody.trigger_id, - actionId, - summary: actionSummary, - }); - const pluginBindingApproval = parsePluginBindingApprovalCustomId(pluginInteractionData); - if (pluginBindingApproval) { - const resolved = await resolvePluginConversationBindingApproval({ - approvalId: pluginBindingApproval.approvalId, - decision: pluginBindingApproval.decision, - senderId: userId, - }); - if (channelId && messageTs) { - try { - await ctx.app.client.chat.update({ - channel: channelId, - ts: messageTs, - text: typedBody.message?.text ?? "", - blocks: [], - }); - } catch { - // Best-effort cleanup only; continue with follow-up feedback. - } - } - if (respond) { - try { - await respond({ - text: buildPluginBindingResolvedText(resolved), - response_type: "ephemeral", - }); - } catch { - // Best-effort feedback only. - } - } - return; - } - const pluginResult = await dispatchPluginInteractiveHandler({ - channel: "slack", - data: pluginInteractionData, - interactionId: pluginInteractionId, - ctx: { - accountId: ctx.accountId, - interactionId: pluginInteractionId, - conversationId: channelId ?? "", - parentConversationId: undefined, - threadId: threadTs, - senderId: userId, - senderUsername: undefined, - auth: { - isAuthorizedSender: auth.allowed, - }, - interaction: { - kind: actionSummary.actionType === "button" ? "button" : "select", - actionId, - blockId, - messageTs, - threadTs, - value: actionSummary.value, - selectedValues: actionSummary.selectedValues, - selectedLabels: actionSummary.selectedLabels, - triggerId: typedBody.trigger_id, - responseUrl: typedBody.response_url, - }, - }, - respond: { - acknowledge: async () => {}, - reply: async ({ text, responseType }) => { - if (!respond) { - return; - } - await respond({ - text, - response_type: responseType ?? "ephemeral", - }); - }, - followUp: async ({ text, responseType }) => { - if (!respond) { - return; - } - await respond({ - text, - response_type: responseType ?? "ephemeral", - }); - }, - editMessage: async ({ text, blocks }) => { - if (!channelId || !messageTs) { - return; - } - await ctx.app.client.chat.update({ - channel: channelId, - ts: messageTs, - text: text ?? typedBody.message?.text ?? "", - ...(Array.isArray(blocks) ? { blocks: blocks as (Block | KnownBlock)[] } : {}), - }); - }, - }, - }); - if (pluginResult.matched && pluginResult.handled) { - return; - } - } - const eventPayload: InteractionSummary = { - interactionType: "block_action", - actionId, - blockId, - ...actionSummary, - userId, - teamId: typedBody.team?.id, - triggerId: typedBody.trigger_id, - responseUrl: typedBody.response_url, - channelId, - messageTs, - threadTs, - }; - - // Log the interaction for debugging - ctx.runtime.log?.( - `slack:interaction action=${actionId} type=${actionSummary.actionType ?? "unknown"} user=${userId} channel=${channelId}`, - ); - - // Send a system event to notify the agent about the button click - // Pass undefined (not "unknown") to allow proper main session fallback - const sessionKey = ctx.resolveSlackSystemEventSessionKey({ - channelId: channelId, - channelType: auth.channelType, - senderId: userId, - }); - - // Build context key - only include defined values to avoid "unknown" noise - const contextParts = ["slack:interaction", channelId, messageTs, actionId].filter(Boolean); - const contextKey = contextParts.join(":"); - - enqueueSystemEvent(formatSlackInteractionSystemEvent(eventPayload), { - sessionKey, - contextKey, - }); - - const originalBlocks = typedBody.message?.blocks; - if (!Array.isArray(originalBlocks) || !channelId || !messageTs) { - return; - } - - if (!blockId) { - return; - } - - const selectedLabel = formatInteractionSelectionLabel({ - actionId, - summary: actionSummary, - buttonText: typedActionWithText.text?.text, - }); - let updatedBlocks = originalBlocks.map((block) => { - const typedBlock = block as InteractionMessageBlock; - if (typedBlock.type === "actions" && typedBlock.block_id === blockId) { - return { - type: "context", - elements: [ - { - type: "mrkdwn", - text: formatInteractionConfirmationText({ selectedLabel, userId }), - }, - ], - }; - } - return block; - }); - - const hasRemainingIndividualActionRows = updatedBlocks.some((block) => { - const typedBlock = block as InteractionMessageBlock; - return typedBlock.type === "actions" && !isBulkActionsBlock(typedBlock); - }); - - if (!hasRemainingIndividualActionRows) { - updatedBlocks = updatedBlocks.filter((block, index) => { - const typedBlock = block as InteractionMessageBlock; - if (isBulkActionsBlock(typedBlock)) { - return false; - } - if (typedBlock.type !== "divider") { - return true; - } - const next = updatedBlocks[index + 1] as InteractionMessageBlock | undefined; - return !next || !isBulkActionsBlock(next); - }); - } - - try { - await ctx.app.client.chat.update({ - channel: channelId, - ts: messageTs, - text: typedBody.message?.text ?? "", - blocks: updatedBlocks as (Block | KnownBlock)[], - }); - } catch { - // If update fails, fallback to ephemeral confirmation for immediate UX feedback. - if (!respond) { - return; - } - try { - await respond({ - text: `Button "${actionId}" clicked!`, - response_type: "ephemeral", - }); - } catch { - // Action was acknowledged and system event enqueued even when response updates fail. - } - } + registerSlackBlockActionHandler({ + ctx, + formatSystemEvent: formatSlackInteractionSystemEvent, }); if (typeof ctx.app.view !== "function") { From 39634088712e5f8530d9063cf5b413c5bf496a18 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:28:08 +0000 Subject: [PATCH 07/23] refactor: split plugin interactive dispatch adapters --- src/plugins/interactive-dispatch-adapters.ts | 219 ++++++++++ src/plugins/interactive.test.ts | 404 ++++++++++++++++++- src/plugins/interactive.ts | 276 ++----------- 3 files changed, 650 insertions(+), 249 deletions(-) create mode 100644 src/plugins/interactive-dispatch-adapters.ts diff --git a/src/plugins/interactive-dispatch-adapters.ts b/src/plugins/interactive-dispatch-adapters.ts new file mode 100644 index 00000000000..4050e707958 --- /dev/null +++ b/src/plugins/interactive-dispatch-adapters.ts @@ -0,0 +1,219 @@ +import { + detachPluginConversationBinding, + getCurrentPluginConversationBinding, + requestPluginConversationBinding, +} from "./conversation-binding.js"; +import type { + PluginConversationBindingRequestParams, + PluginInteractiveDiscordHandlerContext, + PluginInteractiveDiscordHandlerRegistration, + PluginInteractiveSlackHandlerContext, + PluginInteractiveSlackHandlerRegistration, + PluginInteractiveTelegramHandlerContext, + PluginInteractiveTelegramHandlerRegistration, +} from "./types.js"; + +type RegisteredInteractiveMetadata = { + pluginId: string; + pluginName?: string; + pluginRoot?: string; +}; + +type PluginBindingConversation = Parameters< + typeof requestPluginConversationBinding +>[0]["conversation"]; + +export type TelegramInteractiveDispatchContext = Omit< + PluginInteractiveTelegramHandlerContext, + | "callback" + | "respond" + | "channel" + | "requestConversationBinding" + | "detachConversationBinding" + | "getCurrentConversationBinding" +> & { + callbackMessage: { + messageId: number; + chatId: string; + messageText?: string; + }; +}; + +export type DiscordInteractiveDispatchContext = Omit< + PluginInteractiveDiscordHandlerContext, + | "interaction" + | "respond" + | "channel" + | "requestConversationBinding" + | "detachConversationBinding" + | "getCurrentConversationBinding" +> & { + interaction: Omit< + PluginInteractiveDiscordHandlerContext["interaction"], + "data" | "namespace" | "payload" + >; +}; + +export type SlackInteractiveDispatchContext = Omit< + PluginInteractiveSlackHandlerContext, + | "interaction" + | "respond" + | "channel" + | "requestConversationBinding" + | "detachConversationBinding" + | "getCurrentConversationBinding" +> & { + interaction: Omit< + PluginInteractiveSlackHandlerContext["interaction"], + "data" | "namespace" | "payload" + >; +}; + +function createConversationBindingHelpers(params: { + registration: RegisteredInteractiveMetadata; + senderId?: string; + conversation: PluginBindingConversation; +}) { + const { registration, senderId, conversation } = params; + const pluginRoot = registration.pluginRoot; + + return { + requestConversationBinding: async (binding: PluginConversationBindingRequestParams = {}) => { + if (!pluginRoot) { + return { + status: "error" as const, + message: "This interaction cannot bind the current conversation.", + }; + } + return requestPluginConversationBinding({ + pluginId: registration.pluginId, + pluginName: registration.pluginName, + pluginRoot, + requestedBySenderId: senderId, + conversation, + binding, + }); + }, + detachConversationBinding: async () => { + if (!pluginRoot) { + return { removed: false }; + } + return detachPluginConversationBinding({ + pluginRoot, + conversation, + }); + }, + getCurrentConversationBinding: async () => { + if (!pluginRoot) { + return null; + } + return getCurrentPluginConversationBinding({ + pluginRoot, + conversation, + }); + }, + }; +} + +export function dispatchTelegramInteractiveHandler(params: { + registration: PluginInteractiveTelegramHandlerRegistration & RegisteredInteractiveMetadata; + data: string; + namespace: string; + payload: string; + ctx: TelegramInteractiveDispatchContext; + respond: PluginInteractiveTelegramHandlerContext["respond"]; +}) { + const { callbackMessage, ...handlerContext } = params.ctx; + + return params.registration.handler({ + ...handlerContext, + channel: "telegram", + callback: { + data: params.data, + namespace: params.namespace, + payload: params.payload, + messageId: callbackMessage.messageId, + chatId: callbackMessage.chatId, + messageText: callbackMessage.messageText, + }, + respond: params.respond, + ...createConversationBindingHelpers({ + registration: params.registration, + senderId: handlerContext.senderId, + conversation: { + channel: "telegram", + accountId: handlerContext.accountId, + conversationId: handlerContext.conversationId, + parentConversationId: handlerContext.parentConversationId, + threadId: handlerContext.threadId, + }, + }), + }); +} + +export function dispatchDiscordInteractiveHandler(params: { + registration: PluginInteractiveDiscordHandlerRegistration & RegisteredInteractiveMetadata; + data: string; + namespace: string; + payload: string; + ctx: DiscordInteractiveDispatchContext; + respond: PluginInteractiveDiscordHandlerContext["respond"]; +}) { + const handlerContext = params.ctx; + + return params.registration.handler({ + ...handlerContext, + channel: "discord", + interaction: { + ...handlerContext.interaction, + data: params.data, + namespace: params.namespace, + payload: params.payload, + }, + respond: params.respond, + ...createConversationBindingHelpers({ + registration: params.registration, + senderId: handlerContext.senderId, + conversation: { + channel: "discord", + accountId: handlerContext.accountId, + conversationId: handlerContext.conversationId, + parentConversationId: handlerContext.parentConversationId, + }, + }), + }); +} + +export function dispatchSlackInteractiveHandler(params: { + registration: PluginInteractiveSlackHandlerRegistration & RegisteredInteractiveMetadata; + data: string; + namespace: string; + payload: string; + ctx: SlackInteractiveDispatchContext; + respond: PluginInteractiveSlackHandlerContext["respond"]; +}) { + const handlerContext = params.ctx; + + return params.registration.handler({ + ...handlerContext, + channel: "slack", + interaction: { + ...handlerContext.interaction, + data: params.data, + namespace: params.namespace, + payload: params.payload, + }, + respond: params.respond, + ...createConversationBindingHelpers({ + registration: params.registration, + senderId: handlerContext.senderId, + conversation: { + channel: "slack", + accountId: handlerContext.accountId, + conversationId: handlerContext.conversationId, + parentConversationId: handlerContext.parentConversationId, + threadId: handlerContext.threadId, + }, + }), + }); +} diff --git a/src/plugins/interactive.test.ts b/src/plugins/interactive.test.ts index 10caf6dbfa9..14980ec4545 100644 --- a/src/plugins/interactive.test.ts +++ b/src/plugins/interactive.test.ts @@ -1,13 +1,62 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; +import * as conversationBinding from "./conversation-binding.js"; import { clearPluginInteractiveHandlers, dispatchPluginInteractiveHandler, registerPluginInteractiveHandler, } from "./interactive.js"; +let requestPluginConversationBindingMock: MockInstance< + typeof conversationBinding.requestPluginConversationBinding +>; +let detachPluginConversationBindingMock: MockInstance< + typeof conversationBinding.detachPluginConversationBinding +>; +let getCurrentPluginConversationBindingMock: MockInstance< + typeof conversationBinding.getCurrentPluginConversationBinding +>; + describe("plugin interactive handlers", () => { beforeEach(() => { clearPluginInteractiveHandlers(); + requestPluginConversationBindingMock = vi + .spyOn(conversationBinding, "requestPluginConversationBinding") + .mockResolvedValue({ + status: "bound", + binding: { + bindingId: "binding-1", + pluginId: "codex-plugin", + pluginName: "Codex", + pluginRoot: "/plugins/codex", + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + threadId: 77, + boundAt: 1, + }, + }); + detachPluginConversationBindingMock = vi + .spyOn(conversationBinding, "detachPluginConversationBinding") + .mockResolvedValue({ removed: true }); + getCurrentPluginConversationBindingMock = vi + .spyOn(conversationBinding, "getCurrentPluginConversationBinding") + .mockResolvedValue({ + bindingId: "binding-1", + pluginId: "codex-plugin", + pluginName: "Codex", + pluginRoot: "/plugins/codex", + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + threadId: 77, + boundAt: 1, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it("routes Telegram callbacks by namespace and dedupes callback ids", async () => { @@ -213,6 +262,359 @@ describe("plugin interactive handlers", () => { ); }); + it("wires Telegram conversation binding helpers with topic context", async () => { + const requestResult = { + status: "bound" as const, + binding: { + bindingId: "binding-telegram", + pluginId: "codex-plugin", + pluginName: "Codex", + pluginRoot: "/plugins/codex", + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + threadId: 77, + boundAt: 1, + }, + }; + const currentBinding = { + ...requestResult.binding, + boundAt: 2, + }; + requestPluginConversationBindingMock.mockResolvedValueOnce(requestResult); + getCurrentPluginConversationBindingMock.mockResolvedValueOnce(currentBinding); + + const handler = vi.fn(async (ctx) => { + await expect( + ctx.requestConversationBinding({ + summary: "Bind this topic", + detachHint: "Use /new to detach", + }), + ).resolves.toEqual(requestResult); + await expect(ctx.detachConversationBinding()).resolves.toEqual({ removed: true }); + await expect(ctx.getCurrentConversationBinding()).resolves.toEqual(currentBinding); + return { handled: true }; + }); + expect( + registerPluginInteractiveHandler( + "codex-plugin", + { + channel: "telegram", + namespace: "codex", + handler, + }, + { pluginName: "Codex", pluginRoot: "/plugins/codex" }, + ), + ).toEqual({ ok: true }); + + await expect( + dispatchPluginInteractiveHandler({ + channel: "telegram", + data: "codex:bind", + callbackId: "cb-bind", + ctx: { + accountId: "default", + callbackId: "cb-bind", + 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 () => {}), + }, + }), + ).resolves.toEqual({ + matched: true, + handled: true, + duplicate: false, + }); + + expect(requestPluginConversationBindingMock).toHaveBeenCalledWith({ + pluginId: "codex-plugin", + pluginName: "Codex", + pluginRoot: "/plugins/codex", + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + threadId: 77, + }, + binding: { + summary: "Bind this topic", + detachHint: "Use /new to detach", + }, + }); + expect(detachPluginConversationBindingMock).toHaveBeenCalledWith({ + pluginRoot: "/plugins/codex", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + threadId: 77, + }, + }); + expect(getCurrentPluginConversationBindingMock).toHaveBeenCalledWith({ + pluginRoot: "/plugins/codex", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + threadId: 77, + }, + }); + }); + + it("wires Discord conversation binding helpers with parent channel context", async () => { + const requestResult = { + status: "bound" as const, + binding: { + bindingId: "binding-discord", + pluginId: "codex-plugin", + pluginName: "Codex", + pluginRoot: "/plugins/codex", + channel: "discord", + accountId: "default", + conversationId: "channel-1", + parentConversationId: "parent-1", + boundAt: 1, + }, + }; + const currentBinding = { + ...requestResult.binding, + boundAt: 2, + }; + requestPluginConversationBindingMock.mockResolvedValueOnce(requestResult); + getCurrentPluginConversationBindingMock.mockResolvedValueOnce(currentBinding); + + const handler = vi.fn(async (ctx) => { + await expect(ctx.requestConversationBinding({ summary: "Bind Discord" })).resolves.toEqual( + requestResult, + ); + await expect(ctx.detachConversationBinding()).resolves.toEqual({ removed: true }); + await expect(ctx.getCurrentConversationBinding()).resolves.toEqual(currentBinding); + return { handled: true }; + }); + expect( + registerPluginInteractiveHandler( + "codex-plugin", + { + channel: "discord", + namespace: "codex", + handler, + }, + { pluginName: "Codex", pluginRoot: "/plugins/codex" }, + ), + ).toEqual({ ok: true }); + + await expect( + dispatchPluginInteractiveHandler({ + channel: "discord", + data: "codex:bind", + interactionId: "ix-bind", + ctx: { + accountId: "default", + interactionId: "ix-bind", + conversationId: "channel-1", + parentConversationId: "parent-1", + guildId: "guild-1", + senderId: "user-1", + senderUsername: "ada", + auth: { isAuthorizedSender: true }, + interaction: { + kind: "button", + 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 () => {}), + }, + }), + ).resolves.toEqual({ + matched: true, + handled: true, + duplicate: false, + }); + + expect(requestPluginConversationBindingMock).toHaveBeenCalledWith({ + pluginId: "codex-plugin", + pluginName: "Codex", + pluginRoot: "/plugins/codex", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel-1", + parentConversationId: "parent-1", + }, + binding: { + summary: "Bind Discord", + }, + }); + expect(detachPluginConversationBindingMock).toHaveBeenCalledWith({ + pluginRoot: "/plugins/codex", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel-1", + parentConversationId: "parent-1", + }, + }); + expect(getCurrentPluginConversationBindingMock).toHaveBeenCalledWith({ + pluginRoot: "/plugins/codex", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel-1", + parentConversationId: "parent-1", + }, + }); + }); + + it("wires Slack conversation binding helpers with thread context", async () => { + const requestResult = { + status: "bound" as const, + binding: { + bindingId: "binding-slack", + pluginId: "codex-plugin", + pluginName: "Codex", + pluginRoot: "/plugins/codex", + channel: "slack", + accountId: "default", + conversationId: "C123", + parentConversationId: "C123", + threadId: "1710000000.000100", + boundAt: 1, + }, + }; + const currentBinding = { + ...requestResult.binding, + boundAt: 2, + }; + requestPluginConversationBindingMock.mockResolvedValueOnce(requestResult); + getCurrentPluginConversationBindingMock.mockResolvedValueOnce(currentBinding); + + const handler = vi.fn(async (ctx) => { + await expect(ctx.requestConversationBinding({ summary: "Bind Slack" })).resolves.toEqual( + requestResult, + ); + await expect(ctx.detachConversationBinding()).resolves.toEqual({ removed: true }); + await expect(ctx.getCurrentConversationBinding()).resolves.toEqual(currentBinding); + return { handled: true }; + }); + expect( + registerPluginInteractiveHandler( + "codex-plugin", + { + channel: "slack", + namespace: "codex", + handler, + }, + { pluginName: "Codex", pluginRoot: "/plugins/codex" }, + ), + ).toEqual({ ok: true }); + + await expect( + dispatchPluginInteractiveHandler({ + channel: "slack", + data: "codex:bind", + interactionId: "slack-bind", + ctx: { + accountId: "default", + interactionId: "slack-bind", + conversationId: "C123", + parentConversationId: "C123", + threadId: "1710000000.000100", + senderId: "user-1", + senderUsername: "ada", + auth: { isAuthorizedSender: true }, + interaction: { + kind: "button", + actionId: "codex", + blockId: "codex_actions", + messageTs: "1710000000.000200", + threadTs: "1710000000.000100", + value: "bind", + selectedValues: ["bind"], + selectedLabels: ["Bind"], + triggerId: "trigger-1", + responseUrl: "https://hooks.slack.test/response", + }, + }, + respond: { + acknowledge: vi.fn(async () => {}), + reply: vi.fn(async () => {}), + followUp: vi.fn(async () => {}), + editMessage: vi.fn(async () => {}), + }, + }), + ).resolves.toEqual({ + matched: true, + handled: true, + duplicate: false, + }); + + expect(requestPluginConversationBindingMock).toHaveBeenCalledWith({ + pluginId: "codex-plugin", + pluginName: "Codex", + pluginRoot: "/plugins/codex", + requestedBySenderId: "user-1", + conversation: { + channel: "slack", + accountId: "default", + conversationId: "C123", + parentConversationId: "C123", + threadId: "1710000000.000100", + }, + binding: { + summary: "Bind Slack", + }, + }); + expect(detachPluginConversationBindingMock).toHaveBeenCalledWith({ + pluginRoot: "/plugins/codex", + conversation: { + channel: "slack", + accountId: "default", + conversationId: "C123", + parentConversationId: "C123", + threadId: "1710000000.000100", + }, + }); + expect(getCurrentPluginConversationBindingMock).toHaveBeenCalledWith({ + pluginRoot: "/plugins/codex", + conversation: { + channel: "slack", + accountId: "default", + conversationId: "C123", + parentConversationId: "C123", + threadId: "1710000000.000100", + }, + }); + }); + it("does not consume dedupe keys when a handler throws", async () => { const handler = vi .fn(async () => ({ handled: true })) diff --git a/src/plugins/interactive.ts b/src/plugins/interactive.ts index 15561a8af15..04403c80fa2 100644 --- a/src/plugins/interactive.ts +++ b/src/plugins/interactive.ts @@ -1,9 +1,12 @@ import { createDedupeCache } from "../infra/dedupe.js"; import { - detachPluginConversationBinding, - getCurrentPluginConversationBinding, - requestPluginConversationBinding, -} from "./conversation-binding.js"; + dispatchDiscordInteractiveHandler, + dispatchSlackInteractiveHandler, + dispatchTelegramInteractiveHandler, + type DiscordInteractiveDispatchContext, + type SlackInteractiveDispatchContext, + type TelegramInteractiveDispatchContext, +} from "./interactive-dispatch-adapters.js"; import type { PluginInteractiveDiscordHandlerContext, PluginInteractiveButtons, @@ -30,52 +33,6 @@ 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" - >; -}; - -type SlackInteractiveDispatchContext = Omit< - PluginInteractiveSlackHandlerContext, - | "interaction" - | "respond" - | "channel" - | "requestConversationBinding" - | "detachConversationBinding" - | "getCurrentConversationBinding" -> & { - interaction: Omit< - PluginInteractiveSlackHandlerContext["interaction"], - "data" | "namespace" | "payload" - >; -}; - const interactiveHandlers = new Map(); const callbackDedupe = createDedupeCache({ ttlMs: 5 * 60_000, @@ -252,211 +209,34 @@ export async function dispatchPluginInteractiveHandler(params: { | 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, - }, + result = dispatchTelegramInteractiveHandler({ + registration: match.registration as RegisteredInteractiveHandler & + PluginInteractiveTelegramHandlerRegistration, + data: params.data, + namespace: match.namespace, + payload: match.payload, + ctx: params.ctx as TelegramInteractiveDispatchContext, 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 if (params.channel === "discord") { - 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, - }, + result = dispatchDiscordInteractiveHandler({ + registration: match.registration as RegisteredInteractiveHandler & + PluginInteractiveDiscordHandlerRegistration, + data: params.data, + namespace: match.namespace, + payload: match.payload, + ctx: params.ctx as DiscordInteractiveDispatchContext, 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, - }, - }); - }, }); } else { - const pluginRoot = match.registration.pluginRoot; - const handlerContext = params.ctx as SlackInteractiveDispatchContext; - result = ( - match.registration as RegisteredInteractiveHandler & PluginInteractiveSlackHandlerRegistration - ).handler({ - ...handlerContext, - channel: "slack", - interaction: { - ...handlerContext.interaction, - data: params.data, - namespace: match.namespace, - payload: match.payload, - }, + result = dispatchSlackInteractiveHandler({ + registration: match.registration as RegisteredInteractiveHandler & + PluginInteractiveSlackHandlerRegistration, + data: params.data, + namespace: match.namespace, + payload: match.payload, + ctx: params.ctx as SlackInteractiveDispatchContext, respond: params.respond as PluginInteractiveSlackHandlerContext["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: "slack", - 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: "slack", - accountId: handlerContext.accountId, - conversationId: handlerContext.conversationId, - parentConversationId: handlerContext.parentConversationId, - threadId: handlerContext.threadId, - }, - }); - }, - getCurrentConversationBinding: async () => { - if (!pluginRoot) { - return null; - } - return getCurrentPluginConversationBinding({ - pluginRoot, - conversation: { - channel: "slack", - accountId: handlerContext.accountId, - conversationId: handlerContext.conversationId, - parentConversationId: handlerContext.parentConversationId, - threadId: handlerContext.threadId, - }, - }); - }, }); } const resolved = await result; From 7bea559166811732237db003c9ddc7e7e51f8c8e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:32:41 +0000 Subject: [PATCH 08/23] refactor: unify reply content checks --- src/auto-reply/reply/normalize-reply.ts | 57 ++++++++++++++---- src/auto-reply/reply/reply-payloads.ts | 17 +++--- src/auto-reply/reply/route-reply.ts | 11 +++- src/infra/outbound/deliver.ts | 32 +++++----- src/infra/outbound/message-action-runner.ts | 18 +++--- src/infra/outbound/payloads.ts | 20 +++++-- src/interactive/payload.test.ts | 66 +++++++++++++++++++++ src/interactive/payload.ts | 24 ++++++++ 8 files changed, 195 insertions(+), 50 deletions(-) create mode 100644 src/interactive/payload.test.ts diff --git a/src/auto-reply/reply/normalize-reply.ts b/src/auto-reply/reply/normalize-reply.ts index 9ef5a7a9d90..52faa463bdb 100644 --- a/src/auto-reply/reply/normalize-reply.ts +++ b/src/auto-reply/reply/normalize-reply.ts @@ -1,4 +1,5 @@ import { sanitizeUserFacingText } from "../../agents/pi-embedded-helpers.js"; +import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js"; import { stripHeartbeatToken } from "../heartbeat.js"; import { HEARTBEAT_TOKEN, @@ -31,13 +32,17 @@ export function normalizeReplyPayload( payload: ReplyPayload, opts: NormalizeReplyOptions = {}, ): ReplyPayload | null { - const hasMedia = Boolean(payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0); - const hasInteractive = (payload.interactive?.blocks.length ?? 0) > 0; - const hasChannelData = Boolean( - payload.channelData && Object.keys(payload.channelData).length > 0, - ); + const hasChannelData = hasReplyChannelData(payload.channelData); const trimmed = payload.text?.trim() ?? ""; - if (!trimmed && !hasMedia && !hasInteractive && !hasChannelData) { + if ( + !hasReplyContent({ + text: trimmed, + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData, + }) + ) { opts.onSkip?.("empty"); return null; } @@ -45,7 +50,14 @@ export function normalizeReplyPayload( const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN; let text = payload.text ?? undefined; if (text && isSilentReplyText(text, silentToken)) { - if (!hasMedia && !hasInteractive && !hasChannelData) { + if ( + !hasReplyContent({ + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData, + }) + ) { opts.onSkip?.("silent"); return null; } @@ -56,7 +68,15 @@ export function normalizeReplyPayload( // silent just like the exact-match path above. (#30916, #30955) if (text && text.includes(silentToken) && !isSilentReplyText(text, silentToken)) { text = stripSilentToken(text, silentToken); - if (!text && !hasMedia && !hasInteractive && !hasChannelData) { + if ( + !hasReplyContent({ + text, + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData, + }) + ) { opts.onSkip?.("silent"); return null; } @@ -72,7 +92,16 @@ export function normalizeReplyPayload( if (stripped.didStrip) { opts.onHeartbeatStrip?.(); } - if (stripped.shouldSkip && !hasMedia && !hasInteractive && !hasChannelData) { + if ( + stripped.shouldSkip && + !hasReplyContent({ + text: stripped.text, + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData, + }) + ) { opts.onSkip?.("heartbeat"); return null; } @@ -82,7 +111,15 @@ export function normalizeReplyPayload( if (text) { text = sanitizeUserFacingText(text, { errorContext: Boolean(payload.isError) }); } - if (!text?.trim() && !hasMedia && !hasInteractive && !hasChannelData) { + if ( + !hasReplyContent({ + text, + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData, + }) + ) { opts.onSkip?.("empty"); return null; } diff --git a/src/auto-reply/reply/reply-payloads.ts b/src/auto-reply/reply/reply-payloads.ts index ab7586f1664..f5f409e2900 100644 --- a/src/auto-reply/reply/reply-payloads.ts +++ b/src/auto-reply/reply/reply-payloads.ts @@ -4,6 +4,7 @@ import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js"; import { normalizeChannelId } from "../../channels/plugins/index.js"; import type { ReplyToMode } from "../../config/types.js"; import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; +import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js"; import { normalizeOptionalAccountId } from "../../routing/account-id.js"; import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; @@ -74,14 +75,14 @@ export function applyReplyTagsToPayload( } export function isRenderablePayload(payload: ReplyPayload): boolean { - return Boolean( - payload.text || - payload.mediaUrl || - (payload.mediaUrls && payload.mediaUrls.length > 0) || - payload.audioAsVoice || - payload.interactive || - payload.channelData, - ); + return hasReplyContent({ + text: payload.text, + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData: hasReplyChannelData(payload.channelData), + extraContent: payload.audioAsVoice, + }); } export function shouldSuppressReasoningPayload(payload: ReplyPayload): boolean { diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 8dc7499526a..3836ceb5ab6 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -12,6 +12,7 @@ import { resolveEffectiveMessagesConfig } from "../../agents/identity.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { OpenClawConfig } from "../../config/config.js"; import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; +import { hasReplyContent } from "../../interactive/payload.js"; import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; @@ -119,13 +120,19 @@ export async function routeReply(params: RouteReplyParams): Promise 0; const hasChannelData = plugin?.messaging?.hasStructuredReplyPayload?.({ payload: externalPayload, }); // Skip empty replies. - if (!text.trim() && mediaUrls.length === 0 && !hasInteractive && !hasChannelData) { + if ( + !hasReplyContent({ + text, + mediaUrls, + interactive: externalPayload.interactive, + hasChannelData, + }) + ) { return { ok: true }; } diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 9d661b38c45..9e10f525cb0 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -30,6 +30,7 @@ import { toPluginMessageContext, toPluginMessageSentEvent, } from "../../hooks/message-hook-mappers.js"; +import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; @@ -238,30 +239,24 @@ type MessageSentEvent = { messageId?: string; }; -function hasMediaPayload(payload: ReplyPayload): boolean { - return Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; -} - -function hasChannelDataPayload(payload: ReplyPayload): boolean { - return Boolean(payload.channelData && Object.keys(payload.channelData).length > 0); -} - -function hasInteractivePayload(payload: ReplyPayload): boolean { - return (payload.interactive?.blocks.length ?? 0) > 0; -} - function normalizePayloadForChannelDelivery( payload: ReplyPayload, channelId: string, ): ReplyPayload | null { - const hasMedia = hasMediaPayload(payload); - const hasChannelData = hasChannelDataPayload(payload); - const hasInteractive = hasInteractivePayload(payload); + const hasChannelData = hasReplyChannelData(payload.channelData); const rawText = typeof payload.text === "string" ? payload.text : ""; const normalizedText = channelId === "whatsapp" ? rawText.replace(/^(?:[ \t]*\r?\n)+/, "") : rawText; if (!normalizedText.trim()) { - if (!hasMedia && !hasInteractive && !hasChannelData) { + if ( + !hasReplyContent({ + text: normalizedText, + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData, + }) + ) { return null; } return { @@ -713,7 +708,10 @@ async function deliverOutboundPayloadsCore( }; if ( handler.sendPayload && - (effectivePayload.channelData || hasInteractivePayload(effectivePayload)) + (effectivePayload.channelData || + hasReplyContent({ + interactive: effectivePayload.interactive, + })) ) { const delivery = await handler.sendPayload(effectivePayload, sendOverrides); results.push(delivery); diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index aa53f7398f4..8480b962544 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -14,6 +14,7 @@ import type { ChannelThreadingToolContext, } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { hasInteractiveReplyBlocks, hasReplyContent } from "../../interactive/payload.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { hasPollCreationParams, resolveTelegramPollVisibility } from "../../poll-params.js"; import { resolvePollMaxSelections } from "../../polls.js"; @@ -407,7 +408,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise 0; const hasCard = params.card != null && typeof params.card === "object"; const hasComponents = params.components != null && typeof params.components === "object"; - const hasInteractive = params.interactive != null && typeof params.interactive === "object"; + const hasInteractive = hasInteractiveReplyBlocks(params.interactive); const hasBlocks = (Array.isArray(params.blocks) && params.blocks.length > 0) || (typeof params.blocks === "string" && params.blocks.trim().length > 0); @@ -482,14 +483,13 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise 0); - const hasInteractive = Boolean(interactive?.blocks.length); + const hasChannelData = hasReplyChannelData(channelData); + const hasInteractive = hasInteractiveReplyBlocks(interactive); const text = payload.text ?? ""; - if (!text && mediaUrls.length === 0 && !hasInteractive && !hasChannelData) { + if ( + !hasReplyContent({ + text, + mediaUrls, + interactive, + hasChannelData, + }) + ) { continue; } normalizedPayloads.push({ diff --git a/src/interactive/payload.test.ts b/src/interactive/payload.test.ts new file mode 100644 index 00000000000..3000716cd2e --- /dev/null +++ b/src/interactive/payload.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + hasReplyChannelData, + hasReplyContent, + normalizeInteractiveReply, + resolveInteractiveTextFallback, +} from "./payload.js"; + +describe("hasReplyChannelData", () => { + it("accepts non-empty objects only", () => { + expect(hasReplyChannelData(undefined)).toBe(false); + expect(hasReplyChannelData({})).toBe(false); + expect(hasReplyChannelData([])).toBe(false); + expect(hasReplyChannelData({ slack: { blocks: [] } })).toBe(true); + }); +}); + +describe("hasReplyContent", () => { + it("treats whitespace-only text and empty structured payloads as empty", () => { + expect( + hasReplyContent({ + text: " ", + mediaUrls: ["", " "], + interactive: { blocks: [] }, + hasChannelData: false, + }), + ).toBe(false); + }); + + it("accepts shared interactive blocks and explicit extra content", () => { + expect( + hasReplyContent({ + interactive: { + blocks: [{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] }], + }, + }), + ).toBe(true); + expect( + hasReplyContent({ + text: " ", + extraContent: true, + }), + ).toBe(true); + }); +}); + +describe("interactive payload helpers", () => { + it("normalizes interactive replies and resolves text fallbacks", () => { + const interactive = normalizeInteractiveReply({ + blocks: [ + { type: "text", text: "First" }, + { type: "buttons", buttons: [{ label: "Retry", value: "retry" }] }, + { type: "text", text: "Second" }, + ], + }); + + expect(interactive).toEqual({ + blocks: [ + { type: "text", text: "First" }, + { type: "buttons", buttons: [{ label: "Retry", value: "retry" }] }, + { type: "text", text: "Second" }, + ], + }); + expect(resolveInteractiveTextFallback({ interactive })).toBe("First\n\nSecond"); + }); +}); diff --git a/src/interactive/payload.ts b/src/interactive/payload.ts index 6fad12e1f1b..5ccd55d0eff 100644 --- a/src/interactive/payload.ts +++ b/src/interactive/payload.ts @@ -136,6 +136,30 @@ export function hasInteractiveReplyBlocks(value: unknown): value is InteractiveR return Boolean(normalizeInteractiveReply(value)); } +export function hasReplyChannelData(value: unknown): value is Record { + return Boolean( + value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0, + ); +} + +export function hasReplyContent(params: { + text?: string | null; + mediaUrl?: string | null; + mediaUrls?: ReadonlyArray; + interactive?: unknown; + hasChannelData?: boolean; + extraContent?: boolean; +}): boolean { + return Boolean( + params.text?.trim() || + params.mediaUrl?.trim() || + params.mediaUrls?.some((entry) => Boolean(entry?.trim())) || + hasInteractiveReplyBlocks(params.interactive) || + params.hasChannelData || + params.extraContent, + ); +} + export function resolveInteractiveTextFallback(params: { text?: string; interactive?: InteractiveReply; From ff558862f079c83e7435fa2fa4cb06538447ee97 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:35:21 +0000 Subject: [PATCH 09/23] refactor: extract discord shared interactive mapper --- .../discord/src/actions/handle-action.ts | 2 +- extensions/discord/src/components.ts | 66 +------------------ extensions/discord/src/outbound-adapter.ts | 2 +- .../discord/src/shared-interactive.test.ts | 2 +- extensions/discord/src/shared-interactive.ts | 66 +++++++++++++++++++ 5 files changed, 70 insertions(+), 68 deletions(-) create mode 100644 extensions/discord/src/shared-interactive.ts diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts index a1b9caf3b93..4beb7d76de4 100644 --- a/extensions/discord/src/actions/handle-action.ts +++ b/extensions/discord/src/actions/handle-action.ts @@ -10,7 +10,7 @@ import { resolveReactionMessageId } from "../../../../src/channels/plugins/actio import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js"; import { normalizeInteractiveReply } from "../../../../src/interactive/payload.js"; import { readBooleanParam } from "../../../../src/plugin-sdk/boolean-param.js"; -import { buildDiscordInteractiveComponents } from "../components.js"; +import { buildDiscordInteractiveComponents } from "../shared-interactive.js"; import { resolveDiscordChannelId } from "../targets.js"; import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js"; diff --git a/extensions/discord/src/components.ts b/extensions/discord/src/components.ts index 6725ad49a4d..27d29c0dbd7 100644 --- a/extensions/discord/src/components.ts +++ b/extensions/discord/src/components.ts @@ -25,8 +25,6 @@ import { type TopLevelComponents, } from "@buape/carbon"; import { ButtonStyle, MessageFlags, TextInputStyle } from "discord-api-types/v10"; -import { reduceInteractiveReply } from "../../../src/channels/plugins/outbound/interactive.js"; -import type { InteractiveButtonStyle, InteractiveReply } from "../../../src/interactive/payload.js"; export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp"; export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal"; @@ -213,69 +211,7 @@ export type DiscordComponentBuildResult = { entries: DiscordComponentEntry[]; modals: DiscordModalEntry[]; }; - -function resolveDiscordInteractiveButtonStyle( - style?: InteractiveButtonStyle, -): DiscordComponentButtonStyle | undefined { - return style ?? "secondary"; -} - -const DISCORD_INTERACTIVE_BUTTON_ROW_SIZE = 5; - -export function buildDiscordInteractiveComponents( - interactive?: InteractiveReply, -): DiscordComponentMessageSpec | undefined { - const blocks = reduceInteractiveReply( - interactive, - [] as NonNullable, - (state, block) => { - if (block.type === "text") { - const text = block.text.trim(); - if (text) { - state.push({ type: "text", text }); - } - return state; - } - if (block.type === "buttons") { - if (block.buttons.length === 0) { - return state; - } - for ( - let index = 0; - index < block.buttons.length; - index += DISCORD_INTERACTIVE_BUTTON_ROW_SIZE - ) { - state.push({ - type: "actions", - buttons: block.buttons - .slice(index, index + DISCORD_INTERACTIVE_BUTTON_ROW_SIZE) - .map((button) => ({ - label: button.label, - style: resolveDiscordInteractiveButtonStyle(button.style), - callbackData: button.value, - })), - }); - } - return state; - } - if (block.type === "select" && block.options.length > 0) { - state.push({ - type: "actions", - select: { - type: "string", - placeholder: block.placeholder, - options: block.options.map((option) => ({ - label: option.label, - value: option.value, - })), - }, - }); - } - return state; - }, - ); - return blocks.length > 0 ? { blocks } : undefined; -} +export { buildDiscordInteractiveComponents } from "./shared-interactive.js"; const BLOCK_ALIASES = new Map([ ["row", "actions"], diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index 09796a7b0b3..1c6e0111869 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -8,7 +8,6 @@ import type { OpenClawConfig } from "../../../src/config/config.js"; import type { OutboundIdentity } from "../../../src/infra/outbound/identity.js"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import type { DiscordComponentMessageSpec } from "./components.js"; -import { buildDiscordInteractiveComponents } from "./components.js"; import { getThreadBindingManager, type ThreadBindingRecord } from "./monitor/thread-bindings.js"; import { normalizeDiscordOutboundTarget } from "./normalize.js"; import { @@ -17,6 +16,7 @@ import { sendPollDiscord, sendWebhookMessageDiscord, } from "./send.js"; +import { buildDiscordInteractiveComponents } from "./shared-interactive.js"; function resolveDiscordOutboundTarget(params: { to: string; diff --git a/extensions/discord/src/shared-interactive.test.ts b/extensions/discord/src/shared-interactive.test.ts index 827ad1126a8..33ce8f68ec1 100644 --- a/extensions/discord/src/shared-interactive.test.ts +++ b/extensions/discord/src/shared-interactive.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildDiscordInteractiveComponents } from "./components.js"; +import { buildDiscordInteractiveComponents } from "./shared-interactive.js"; describe("buildDiscordInteractiveComponents", () => { it("maps shared buttons and selects into Discord component blocks", () => { diff --git a/extensions/discord/src/shared-interactive.ts b/extensions/discord/src/shared-interactive.ts new file mode 100644 index 00000000000..d99f964f5c9 --- /dev/null +++ b/extensions/discord/src/shared-interactive.ts @@ -0,0 +1,66 @@ +import { reduceInteractiveReply } from "../../../src/channels/plugins/outbound/interactive.js"; +import type { InteractiveButtonStyle, InteractiveReply } from "../../../src/interactive/payload.js"; +import type { DiscordComponentButtonStyle, DiscordComponentMessageSpec } from "./components.js"; + +function resolveDiscordInteractiveButtonStyle( + style?: InteractiveButtonStyle, +): DiscordComponentButtonStyle | undefined { + return style ?? "secondary"; +} + +const DISCORD_INTERACTIVE_BUTTON_ROW_SIZE = 5; + +export function buildDiscordInteractiveComponents( + interactive?: InteractiveReply, +): DiscordComponentMessageSpec | undefined { + const blocks = reduceInteractiveReply( + interactive, + [] as NonNullable, + (state, block) => { + if (block.type === "text") { + const text = block.text.trim(); + if (text) { + state.push({ type: "text", text }); + } + return state; + } + if (block.type === "buttons") { + if (block.buttons.length === 0) { + return state; + } + for ( + let index = 0; + index < block.buttons.length; + index += DISCORD_INTERACTIVE_BUTTON_ROW_SIZE + ) { + state.push({ + type: "actions", + buttons: block.buttons + .slice(index, index + DISCORD_INTERACTIVE_BUTTON_ROW_SIZE) + .map((button) => ({ + label: button.label, + style: resolveDiscordInteractiveButtonStyle(button.style), + callbackData: button.value, + })), + }); + } + return state; + } + if (block.type === "select" && block.options.length > 0) { + state.push({ + type: "actions", + select: { + type: "string", + placeholder: block.placeholder, + options: block.options.map((option) => ({ + label: option.label, + value: option.value, + })), + }, + }); + } + return state; + }, + ); + return blocks.length > 0 ? { blocks } : undefined; +} From ecaafb6a4f2981702b8e4785476affee74779dd7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:37:12 +0000 Subject: [PATCH 10/23] refactor: unify telegram interactive button resolution --- extensions/telegram/src/button-types.test.ts | 69 ++++++++++++++++++++ extensions/telegram/src/button-types.ts | 15 ++++- extensions/telegram/src/channel-actions.ts | 10 +-- extensions/telegram/src/outbound-adapter.ts | 8 ++- 4 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 extensions/telegram/src/button-types.test.ts diff --git a/extensions/telegram/src/button-types.test.ts b/extensions/telegram/src/button-types.test.ts new file mode 100644 index 00000000000..849caac62ac --- /dev/null +++ b/extensions/telegram/src/button-types.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { buildTelegramInteractiveButtons, resolveTelegramInlineButtons } from "./button-types.js"; + +describe("buildTelegramInteractiveButtons", () => { + it("maps shared buttons and selects into Telegram inline rows", () => { + expect( + buildTelegramInteractiveButtons({ + blocks: [ + { + type: "buttons", + buttons: [ + { label: "Approve", value: "approve", style: "success" }, + { label: "Reject", value: "reject", style: "danger" }, + { label: "Later", value: "later" }, + { label: "Archive", value: "archive" }, + ], + }, + { + type: "select", + options: [{ label: "Alpha", value: "alpha" }], + }, + ], + }), + ).toEqual([ + [ + { text: "Approve", callback_data: "approve", style: "success" }, + { text: "Reject", callback_data: "reject", style: "danger" }, + { text: "Later", callback_data: "later", style: undefined }, + ], + [{ text: "Archive", callback_data: "archive", style: undefined }], + [{ text: "Alpha", callback_data: "alpha", style: undefined }], + ]); + }); +}); + +describe("resolveTelegramInlineButtons", () => { + it("prefers explicit buttons over shared interactive blocks", () => { + const explicit = [[{ text: "Keep", callback_data: "keep" }]] as const; + + expect( + resolveTelegramInlineButtons({ + buttons: explicit, + interactive: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Override", value: "override" }], + }, + ], + }, + }), + ).toBe(explicit); + }); + + it("derives buttons from raw interactive payloads", () => { + expect( + resolveTelegramInlineButtons({ + interactive: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Retry", value: "retry", style: "primary" }], + }, + ], + }, + }), + ).toEqual([[{ text: "Retry", callback_data: "retry", style: "primary" }]]); + }); +}); diff --git a/extensions/telegram/src/button-types.ts b/extensions/telegram/src/button-types.ts index f9c77ac190b..a6eae71995b 100644 --- a/extensions/telegram/src/button-types.ts +++ b/extensions/telegram/src/button-types.ts @@ -1,5 +1,9 @@ import { reduceInteractiveReply } from "../../../src/channels/plugins/outbound/interactive.js"; -import type { InteractiveReply, InteractiveReplyButton } from "../../../src/interactive/payload.js"; +import { + normalizeInteractiveReply, + type InteractiveReply, + type InteractiveReplyButton, +} from "../../../src/interactive/payload.js"; export type TelegramButtonStyle = "danger" | "success" | "primary"; @@ -60,3 +64,12 @@ export function buildTelegramInteractiveButtons( ); return rows.length > 0 ? rows : undefined; } + +export function resolveTelegramInlineButtons(params: { + buttons?: TelegramInlineButtons; + interactive?: unknown; +}): TelegramInlineButtons | undefined { + return ( + params.buttons ?? buildTelegramInteractiveButtons(normalizeInteractiveReply(params.interactive)) + ); +} diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index 246ed45c0e3..84548374f05 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -15,7 +15,6 @@ import type { ChannelMessageActionName, } from "../../../src/channels/plugins/types.js"; import type { TelegramActionConfig } from "../../../src/config/types.telegram.js"; -import { normalizeInteractiveReply } from "../../../src/interactive/payload.js"; import { readBooleanParam } from "../../../src/plugin-sdk/boolean-param.js"; import { extractToolSend } from "../../../src/plugin-sdk/tool-send.js"; import { resolveTelegramPollVisibility } from "../../../src/poll-params.js"; @@ -24,7 +23,7 @@ import { listEnabledTelegramAccounts, resolveTelegramPollActionGateState, } from "./accounts.js"; -import { buildTelegramInteractiveButtons } from "./button-types.js"; +import { resolveTelegramInlineButtons } from "./button-types.js"; import { isTelegramInlineButtonsEnabled } from "./inline-buttons.js"; const providerId = "telegram"; @@ -32,9 +31,10 @@ const providerId = "telegram"; function readTelegramSendParams(params: Record) { const to = readStringParam(params, "to", { required: true }); const mediaUrl = readStringParam(params, "media", { trim: false }); - const buttons = - params.buttons ?? - buildTelegramInteractiveButtons(normalizeInteractiveReply(params.interactive)); + const buttons = resolveTelegramInlineButtons({ + buttons: params.buttons as ReturnType, + interactive: params.interactive, + }); const hasButtons = Array.isArray(buttons) && buttons.length > 0; const message = readStringParam(params, "message", { required: !mediaUrl && !hasButtons, diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index b5ed5ccfcb4..e8c0530d06b 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -10,7 +10,7 @@ import { } from "../../../src/infra/outbound/send-deps.js"; import { resolveInteractiveTextFallback } from "../../../src/interactive/payload.js"; import type { TelegramInlineButtons } from "./button-types.js"; -import { buildTelegramInteractiveButtons } from "./button-types.js"; +import { resolveTelegramInlineButtons } from "./button-types.js"; import { markdownToTelegramHtmlChunks } from "./format.js"; import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js"; import { sendMessageTelegram } from "./send.js"; @@ -67,8 +67,10 @@ export async function sendTelegramPayloadMessages(params: { interactive: params.payload.interactive, }) ?? ""; const mediaUrls = resolvePayloadMediaUrls(params.payload); - const interactiveButtons = buildTelegramInteractiveButtons(params.payload.interactive); - const buttons = telegramData?.buttons ?? interactiveButtons; + const buttons = resolveTelegramInlineButtons({ + buttons: telegramData?.buttons, + interactive: params.payload.interactive, + }); const payloadOpts = { ...params.baseOpts, quoteText, From 2852eab323bd5e9a9c41bedf8e79a2e4fac35a24 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:40:07 +0000 Subject: [PATCH 11/23] build: add land gate parity script --- docs/ci.md | 3 +++ package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/docs/ci.md b/docs/ci.md index e8710b87cb1..25445d6c0ed 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -53,4 +53,7 @@ pnpm check # types + lint + format pnpm test # vitest tests pnpm check:docs # docs format + lint + broken links pnpm release:check # validate npm pack +pnpm land:gate # maintainer land gate: frozen-lock install + check + build + test + release:check ``` + +`pnpm land:gate` intentionally includes the same frozen-lockfile install step CI uses before running `check`, `build`, `test`, and `release:check`. Use it when you want local merge-gate parity instead of piecemeal commands. diff --git a/package.json b/package.json index caa950adf1f..6aa553f5302 100644 --- a/package.json +++ b/package.json @@ -270,6 +270,7 @@ "ios:gen": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate'", "ios:open": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'", "ios:run": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted ai.openclaw.ios'", + "land:gate": "pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true && pnpm check && pnpm build && pnpm test && pnpm release:check", "lint": "oxlint --type-aware", "lint:agent:ingress-owner": "node scripts/check-ingress-agent-owner-context.mjs", "lint:all": "pnpm lint && pnpm lint:swift", From 465567b1eb1cbb03a37734ea1a3ac78a32f60a72 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:44:31 +0000 Subject: [PATCH 12/23] test: fix setup wizard smoke mocks --- src/cli/program.test-mocks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/program.test-mocks.ts b/src/cli/program.test-mocks.ts index 8f82e71fca5..15595755dc3 100644 --- a/src/cli/program.test-mocks.ts +++ b/src/cli/program.test-mocks.ts @@ -34,8 +34,8 @@ 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 setupWizardCommand = programMocks.setupWizardCommand as AnyMock; export const onboardCommand = programMocks.onboardCommand as AnyMock; +export const setupWizardCommand = programMocks.setupWizardCommand as AnyMock; export const callGateway = programMocks.callGateway as AnyMock; export const runChannelLogin = programMocks.runChannelLogin as AnyMock; export const runChannelLogout = programMocks.runChannelLogout as AnyMock; From 0a6f22a69478573f0e8d0ccb622dfac15f33fd43 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 05:49:29 +0000 Subject: [PATCH 13/23] docs: sync config baseline --- docs/.generated/config-baseline.json | 10 +++++----- docs/.generated/config-baseline.jsonl | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index dee3827bbcc..8a30b9c6fde 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -58804,7 +58804,7 @@ "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.", + "help": "Setup wizard state tracking fields that record the most recent guided setup run details. Keep these fields for observability and troubleshooting of setup flows across upgrades.", "hasChildren": true }, { @@ -58818,7 +58818,7 @@ "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.", + "help": "ISO timestamp for when the setup wizard most recently completed on this host. Use this to confirm setup recency during support and operational audits.", "hasChildren": false }, { @@ -58832,7 +58832,7 @@ "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.", + "help": "Command invocation recorded for the latest wizard run to preserve execution context. Use this to reproduce setup steps when verifying setup regressions.", "hasChildren": false }, { @@ -58846,7 +58846,7 @@ "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.", + "help": "Source commit identifier recorded for the last wizard execution in development builds. Use this to correlate setup behavior with exact source state during debugging.", "hasChildren": false }, { @@ -58874,7 +58874,7 @@ "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.", + "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 setup changes.", "hasChildren": false } ] diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index 004f48478bb..f8a5068394e 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -5086,9 +5086,9 @@ {"recordType":"path","path":"web.reconnect.jitter","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"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} {"recordType":"path","path":"web.reconnect.maxAttempts","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"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} {"recordType":"path","path":"web.reconnect.maxMs","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"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} -{"recordType":"path","path":"wizard","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"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} -{"recordType":"path","path":"wizard.lastRunAt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"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} -{"recordType":"path","path":"wizard.lastRunCommand","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"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} -{"recordType":"path","path":"wizard.lastRunCommit","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"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} +{"recordType":"path","path":"wizard","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Setup Wizard State","help":"Setup wizard state tracking fields that record the most recent guided setup run details. Keep these fields for observability and troubleshooting of setup flows across upgrades.","hasChildren":true} +{"recordType":"path","path":"wizard.lastRunAt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"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 setup recency during support and operational audits.","hasChildren":false} +{"recordType":"path","path":"wizard.lastRunCommand","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Wizard Last Run Command","help":"Command invocation recorded for the latest wizard run to preserve execution context. Use this to reproduce setup steps when verifying setup regressions.","hasChildren":false} +{"recordType":"path","path":"wizard.lastRunCommit","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Wizard Last Run Commit","help":"Source commit identifier recorded for the last wizard execution in development builds. Use this to correlate setup behavior with exact source state during debugging.","hasChildren":false} {"recordType":"path","path":"wizard.lastRunMode","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Wizard Last Run Mode","help":"Wizard execution mode recorded as \"local\" or \"remote\" for the most recent setup flow. Use this to understand whether setup targeted direct local runtime or remote gateway topology.","hasChildren":false} -{"recordType":"path","path":"wizard.lastRunVersion","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"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} +{"recordType":"path","path":"wizard.lastRunVersion","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"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 setup changes.","hasChildren":false} From ebfd32efc31cb26a204358811006dd9d6cc318a9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:26:15 -0700 Subject: [PATCH 14/23] Status: split heartbeat summary helpers --- src/commands/health.ts | 2 +- src/commands/status.summary.test.ts | 2 +- src/commands/status.summary.ts | 2 +- src/infra/heartbeat-runner.ts | 114 +++------------------------ src/infra/heartbeat-summary.ts | 118 ++++++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 105 deletions(-) create mode 100644 src/infra/heartbeat-summary.ts diff --git a/src/commands/health.ts b/src/commands/health.ts index ddfc308bda4..301cb55282e 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -14,7 +14,7 @@ import { formatErrorMessage } from "../infra/errors.js"; import { type HeartbeatSummary, resolveHeartbeatSummaryForAgent, -} from "../infra/heartbeat-runner.js"; +} from "../infra/heartbeat-summary.js"; import { buildChannelAccountBindings, resolvePreferredAccountId } from "../routing/bindings.js"; import { normalizeAgentId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index c0344065126..2045c380e1b 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -48,7 +48,7 @@ vi.mock("../infra/channel-summary.js", () => ({ buildChannelSummary: vi.fn(async () => ["ok"]), })); -vi.mock("../infra/heartbeat-runner.js", () => ({ +vi.mock("../infra/heartbeat-summary.js", () => ({ resolveHeartbeatSummaryForAgent: vi.fn(() => ({ enabled: true, every: "5m", diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index b028c99ab6d..6de3b282648 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -16,7 +16,7 @@ import { listAgentsForGateway, resolveSessionModelRef, } from "../gateway/session-utils.js"; -import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-runner.js"; +import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js"; import { peekSystemEvents } from "../infra/system-events.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; import { resolveRuntimeServiceVersion } from "../version.js"; diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 1f6ae8767e9..34b3a7b5f86 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -11,7 +11,6 @@ import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js"; import { resolveHeartbeatReplyPayload } from "../auto-reply/heartbeat-reply-payload.js"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, - DEFAULT_HEARTBEAT_EVERY, isHeartbeatContentEffectivelyEmpty, resolveHeartbeatPrompt as resolveHeartbeatPromptText, stripHeartbeatToken, @@ -21,7 +20,6 @@ import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import { getChannelPlugin } from "../channels/plugins/index.js"; import type { ChannelHeartbeatDeps } from "../channels/plugins/types.js"; -import { parseDurationMs } from "../cli/parse-duration.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { @@ -56,6 +54,12 @@ import { } from "./heartbeat-events-filter.js"; import { emitHeartbeatEvent, resolveIndicatorType } from "./heartbeat-events.js"; import { resolveHeartbeatReasonKind } from "./heartbeat-reason.js"; +import { + isHeartbeatEnabledForAgent, + resolveHeartbeatIntervalMs, + resolveHeartbeatSummaryForAgent, + type HeartbeatSummary, +} from "./heartbeat-summary.js"; import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js"; import { areHeartbeatsEnabled, @@ -84,6 +88,12 @@ export type HeartbeatDeps = OutboundSendDeps & const log = createSubsystemLogger("gateway/heartbeat"); export { areHeartbeatsEnabled, setHeartbeatsEnabled }; +export { + isHeartbeatEnabledForAgent, + resolveHeartbeatIntervalMs, + resolveHeartbeatSummaryForAgent, + type HeartbeatSummary, +} from "./heartbeat-summary.js"; type HeartbeatConfig = AgentDefaultsConfig["heartbeat"]; type HeartbeatAgent = { @@ -91,17 +101,6 @@ type HeartbeatAgent = { heartbeat?: HeartbeatConfig; }; -export type HeartbeatSummary = { - enabled: boolean; - every: string; - everyMs: number | null; - prompt: string; - target: string; - model?: string; - ackMaxChars: number; -}; - -const DEFAULT_HEARTBEAT_TARGET = "none"; export { isCronSystemEvent }; type HeartbeatAgentState = { @@ -122,18 +121,6 @@ function hasExplicitHeartbeatAgents(cfg: OpenClawConfig) { return list.some((entry) => Boolean(entry?.heartbeat)); } -export function isHeartbeatEnabledForAgent(cfg: OpenClawConfig, agentId?: string): boolean { - const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg)); - const list = cfg.agents?.list ?? []; - const hasExplicit = hasExplicitHeartbeatAgents(cfg); - if (hasExplicit) { - return list.some( - (entry) => Boolean(entry?.heartbeat) && normalizeAgentId(entry?.id) === resolvedAgentId, - ); - } - return resolvedAgentId === resolveDefaultAgentId(cfg); -} - function resolveHeartbeatConfig( cfg: OpenClawConfig, agentId?: string, @@ -149,54 +136,6 @@ function resolveHeartbeatConfig( return { ...defaults, ...overrides }; } -export function resolveHeartbeatSummaryForAgent( - cfg: OpenClawConfig, - agentId?: string, -): HeartbeatSummary { - const defaults = cfg.agents?.defaults?.heartbeat; - const overrides = agentId ? resolveAgentConfig(cfg, agentId)?.heartbeat : undefined; - const enabled = isHeartbeatEnabledForAgent(cfg, agentId); - - if (!enabled) { - return { - enabled: false, - every: "disabled", - everyMs: null, - prompt: resolveHeartbeatPromptText(defaults?.prompt), - target: defaults?.target ?? DEFAULT_HEARTBEAT_TARGET, - model: defaults?.model, - ackMaxChars: Math.max(0, defaults?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS), - }; - } - - const merged = defaults || overrides ? { ...defaults, ...overrides } : undefined; - const every = merged?.every ?? defaults?.every ?? overrides?.every ?? DEFAULT_HEARTBEAT_EVERY; - const everyMs = resolveHeartbeatIntervalMs(cfg, undefined, merged); - const prompt = resolveHeartbeatPromptText( - merged?.prompt ?? defaults?.prompt ?? overrides?.prompt, - ); - const target = - merged?.target ?? defaults?.target ?? overrides?.target ?? DEFAULT_HEARTBEAT_TARGET; - const model = merged?.model ?? defaults?.model ?? overrides?.model; - const ackMaxChars = Math.max( - 0, - merged?.ackMaxChars ?? - defaults?.ackMaxChars ?? - overrides?.ackMaxChars ?? - DEFAULT_HEARTBEAT_ACK_MAX_CHARS, - ); - - return { - enabled: true, - every, - everyMs, - prompt, - target, - model, - ackMaxChars, - }; -} - function resolveHeartbeatAgents(cfg: OpenClawConfig): HeartbeatAgent[] { const list = cfg.agents?.list ?? []; if (hasExplicitHeartbeatAgents(cfg)) { @@ -212,35 +151,6 @@ function resolveHeartbeatAgents(cfg: OpenClawConfig): HeartbeatAgent[] { return [{ agentId: fallbackId, heartbeat: resolveHeartbeatConfig(cfg, fallbackId) }]; } -export function resolveHeartbeatIntervalMs( - cfg: OpenClawConfig, - overrideEvery?: string, - heartbeat?: HeartbeatConfig, -) { - const raw = - overrideEvery ?? - heartbeat?.every ?? - cfg.agents?.defaults?.heartbeat?.every ?? - DEFAULT_HEARTBEAT_EVERY; - if (!raw) { - return null; - } - const trimmed = String(raw).trim(); - if (!trimmed) { - return null; - } - let ms: number; - try { - ms = parseDurationMs(trimmed, { defaultUnit: "m" }); - } catch { - return null; - } - if (ms <= 0) { - return null; - } - return ms; -} - export function resolveHeartbeatPrompt(cfg: OpenClawConfig, heartbeat?: HeartbeatConfig) { return resolveHeartbeatPromptText(heartbeat?.prompt ?? cfg.agents?.defaults?.heartbeat?.prompt); } diff --git a/src/infra/heartbeat-summary.ts b/src/infra/heartbeat-summary.ts new file mode 100644 index 00000000000..89650de44a6 --- /dev/null +++ b/src/infra/heartbeat-summary.ts @@ -0,0 +1,118 @@ +import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { + DEFAULT_HEARTBEAT_ACK_MAX_CHARS, + DEFAULT_HEARTBEAT_EVERY, + resolveHeartbeatPrompt as resolveHeartbeatPromptText, +} from "../auto-reply/heartbeat.js"; +import { parseDurationMs } from "../cli/parse-duration.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; +import { normalizeAgentId } from "../routing/session-key.js"; + +type HeartbeatConfig = AgentDefaultsConfig["heartbeat"]; + +export type HeartbeatSummary = { + enabled: boolean; + every: string; + everyMs: number | null; + prompt: string; + target: string; + model?: string; + ackMaxChars: number; +}; + +const DEFAULT_HEARTBEAT_TARGET = "none"; + +function hasExplicitHeartbeatAgents(cfg: OpenClawConfig) { + const list = cfg.agents?.list ?? []; + return list.some((entry) => Boolean(entry?.heartbeat)); +} + +export function isHeartbeatEnabledForAgent(cfg: OpenClawConfig, agentId?: string): boolean { + const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg)); + const list = cfg.agents?.list ?? []; + const hasExplicit = hasExplicitHeartbeatAgents(cfg); + if (hasExplicit) { + return list.some( + (entry) => Boolean(entry?.heartbeat) && normalizeAgentId(entry?.id) === resolvedAgentId, + ); + } + return resolvedAgentId === resolveDefaultAgentId(cfg); +} + +export function resolveHeartbeatIntervalMs( + cfg: OpenClawConfig, + overrideEvery?: string, + heartbeat?: HeartbeatConfig, +) { + const raw = + overrideEvery ?? + heartbeat?.every ?? + cfg.agents?.defaults?.heartbeat?.every ?? + DEFAULT_HEARTBEAT_EVERY; + if (!raw) { + return null; + } + const trimmed = String(raw).trim(); + if (!trimmed) { + return null; + } + let ms: number; + try { + ms = parseDurationMs(trimmed, { defaultUnit: "m" }); + } catch { + return null; + } + if (ms <= 0) { + return null; + } + return ms; +} + +export function resolveHeartbeatSummaryForAgent( + cfg: OpenClawConfig, + agentId?: string, +): HeartbeatSummary { + const defaults = cfg.agents?.defaults?.heartbeat; + const overrides = agentId ? resolveAgentConfig(cfg, agentId)?.heartbeat : undefined; + const enabled = isHeartbeatEnabledForAgent(cfg, agentId); + + if (!enabled) { + return { + enabled: false, + every: "disabled", + everyMs: null, + prompt: resolveHeartbeatPromptText(defaults?.prompt), + target: defaults?.target ?? DEFAULT_HEARTBEAT_TARGET, + model: defaults?.model, + ackMaxChars: Math.max(0, defaults?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS), + }; + } + + const merged = defaults || overrides ? { ...defaults, ...overrides } : undefined; + const every = merged?.every ?? defaults?.every ?? overrides?.every ?? DEFAULT_HEARTBEAT_EVERY; + const everyMs = resolveHeartbeatIntervalMs(cfg, undefined, merged); + const prompt = resolveHeartbeatPromptText( + merged?.prompt ?? defaults?.prompt ?? overrides?.prompt, + ); + const target = + merged?.target ?? defaults?.target ?? overrides?.target ?? DEFAULT_HEARTBEAT_TARGET; + const model = merged?.model ?? defaults?.model ?? overrides?.model; + const ackMaxChars = Math.max( + 0, + merged?.ackMaxChars ?? + defaults?.ackMaxChars ?? + overrides?.ackMaxChars ?? + DEFAULT_HEARTBEAT_ACK_MAX_CHARS, + ); + + return { + enabled: true, + every, + everyMs, + prompt, + target, + model, + ackMaxChars, + }; +} From 4cb46f223c4bf7e5e91721d74418fe76ea128c30 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:39:48 -0700 Subject: [PATCH 15/23] Security: trim audit policy import surfaces --- src/agents/pi-tools.policy.ts | 48 +++---------------------------- src/agents/tool-policy-match.ts | 44 ++++++++++++++++++++++++++++ src/gateway/hooks-policy.ts | 24 ++++++++++++++++ src/gateway/hooks.ts | 26 ++--------------- src/security/audit-extra.async.ts | 8 ++---- src/security/audit-extra.sync.ts | 10 +++---- 6 files changed, 81 insertions(+), 79 deletions(-) create mode 100644 src/agents/tool-policy-match.ts create mode 100644 src/gateway/hooks-policy.ts diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index 0353c454865..a6f8651f72d 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -7,7 +7,6 @@ import { normalizeAgentId } from "../routing/session-key.js"; import { resolveThreadParentSessionKey } from "../sessions/session-key-utils.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { resolveAgentConfig, resolveAgentIdFromSessionKey } from "./agent-scope.js"; -import { compileGlobPatterns, matchesAnyGlobPattern } from "./glob-pattern.js"; import type { AnyAgentTool } from "./pi-tools.types.js"; import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js"; import type { SandboxToolPolicy } from "./sandbox.js"; @@ -15,34 +14,8 @@ import { resolveStoredSubagentCapabilities, type SubagentSessionRole, } from "./subagent-capabilities.js"; -import { expandToolGroups, normalizeToolName } from "./tool-policy.js"; - -function makeToolPolicyMatcher(policy: SandboxToolPolicy) { - const deny = compileGlobPatterns({ - raw: expandToolGroups(policy.deny ?? []), - normalize: normalizeToolName, - }); - const allow = compileGlobPatterns({ - raw: expandToolGroups(policy.allow ?? []), - normalize: normalizeToolName, - }); - return (name: string) => { - const normalized = normalizeToolName(name); - if (matchesAnyGlobPattern(normalized, deny)) { - return false; - } - if (allow.length === 0) { - return true; - } - if (matchesAnyGlobPattern(normalized, allow)) { - return true; - } - if (normalized === "apply_patch" && matchesAnyGlobPattern("exec", allow)) { - return true; - } - return false; - }; -} +import { isToolAllowedByPolicies, isToolAllowedByPolicyName } from "./tool-policy-match.js"; +import { normalizeToolName } from "./tool-policy.js"; /** * Tools always denied for sub-agents regardless of depth. @@ -140,19 +113,11 @@ export function resolveSubagentToolPolicyForSession( return { allow: mergedAllow, deny }; } -export function isToolAllowedByPolicyName(name: string, policy?: SandboxToolPolicy): boolean { - if (!policy) { - return true; - } - return makeToolPolicyMatcher(policy)(name); -} - export function filterToolsByPolicy(tools: AnyAgentTool[], policy?: SandboxToolPolicy) { if (!policy) { return tools; } - const matcher = makeToolPolicyMatcher(policy); - return tools.filter((tool) => matcher(tool.name)); + return tools.filter((tool) => isToolAllowedByPolicyName(tool.name, policy)); } type ToolPolicyConfig = { @@ -381,9 +346,4 @@ export function resolveGroupToolPolicy(params: { return pickSandboxToolPolicy(toolsConfig); } -export function isToolAllowedByPolicies( - name: string, - policies: Array, -) { - return policies.every((policy) => isToolAllowedByPolicyName(name, policy)); -} +export { isToolAllowedByPolicies, isToolAllowedByPolicyName } from "./tool-policy-match.js"; diff --git a/src/agents/tool-policy-match.ts b/src/agents/tool-policy-match.ts new file mode 100644 index 00000000000..112bd94be10 --- /dev/null +++ b/src/agents/tool-policy-match.ts @@ -0,0 +1,44 @@ +import { compileGlobPatterns, matchesAnyGlobPattern } from "./glob-pattern.js"; +import type { SandboxToolPolicy } from "./sandbox/types.js"; +import { expandToolGroups, normalizeToolName } from "./tool-policy.js"; + +function makeToolPolicyMatcher(policy: SandboxToolPolicy) { + const deny = compileGlobPatterns({ + raw: expandToolGroups(policy.deny ?? []), + normalize: normalizeToolName, + }); + const allow = compileGlobPatterns({ + raw: expandToolGroups(policy.allow ?? []), + normalize: normalizeToolName, + }); + return (name: string) => { + const normalized = normalizeToolName(name); + if (matchesAnyGlobPattern(normalized, deny)) { + return false; + } + if (allow.length === 0) { + return true; + } + if (matchesAnyGlobPattern(normalized, allow)) { + return true; + } + if (normalized === "apply_patch" && matchesAnyGlobPattern("exec", allow)) { + return true; + } + return false; + }; +} + +export function isToolAllowedByPolicyName(name: string, policy?: SandboxToolPolicy): boolean { + if (!policy) { + return true; + } + return makeToolPolicyMatcher(policy)(name); +} + +export function isToolAllowedByPolicies( + name: string, + policies: Array, +) { + return policies.every((policy) => isToolAllowedByPolicyName(name, policy)); +} diff --git a/src/gateway/hooks-policy.ts b/src/gateway/hooks-policy.ts new file mode 100644 index 00000000000..27ce19b40cf --- /dev/null +++ b/src/gateway/hooks-policy.ts @@ -0,0 +1,24 @@ +import { normalizeAgentId } from "../routing/session-key.js"; + +export function resolveAllowedAgentIds(raw: string[] | undefined): Set | undefined { + if (!Array.isArray(raw)) { + return undefined; + } + const allowed = new Set(); + let hasWildcard = false; + for (const entry of raw) { + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + if (trimmed === "*") { + hasWildcard = true; + break; + } + allowed.add(normalizeAgentId(trimmed)); + } + if (hasWildcard) { + return undefined; + } + return allowed; +} diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index f371e3565a9..d9e23060f04 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -5,9 +5,10 @@ import { listChannelPlugins } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { readJsonBodyWithLimit, requestBodyErrorToText } from "../infra/http-body.js"; -import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; +import { parseAgentSessionKey } from "../routing/session-key.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { type HookMappingResolved, resolveHookMappings } from "./hooks-mapping.js"; +import { resolveAllowedAgentIds } from "./hooks-policy.js"; const DEFAULT_HOOKS_PATH = "/hooks"; const DEFAULT_HOOKS_MAX_BODY_BYTES = 256 * 1024; @@ -100,29 +101,6 @@ function resolveKnownAgentIds(cfg: OpenClawConfig, defaultAgentId: string): Set< return known; } -export function resolveAllowedAgentIds(raw: string[] | undefined): Set | undefined { - if (!Array.isArray(raw)) { - return undefined; - } - const allowed = new Set(); - let hasWildcard = false; - for (const entry of raw) { - const trimmed = entry.trim(); - if (!trimmed) { - continue; - } - if (trimmed === "*") { - hasWildcard = true; - break; - } - allowed.add(normalizeAgentId(trimmed)); - } - if (hasWildcard) { - return undefined; - } - return allowed; -} - function resolveSessionKey(raw: string | undefined): string | undefined { const value = raw?.trim(); return value ? value : undefined; diff --git a/src/security/audit-extra.async.ts b/src/security/audit-extra.async.ts index 7ad36855852..88df46bafa1 100644 --- a/src/security/audit-extra.async.ts +++ b/src/security/audit-extra.async.ts @@ -6,15 +6,13 @@ import fs from "node:fs/promises"; import path from "node:path"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { isToolAllowedByPolicies } from "../agents/pi-tools.policy.js"; -import { - resolveSandboxConfigForAgent, - resolveSandboxToolPolicyForAgent, -} from "../agents/sandbox.js"; +import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js"; import { SANDBOX_BROWSER_SECURITY_HASH_EPOCH } from "../agents/sandbox/constants.js"; import { execDockerRaw, type ExecDockerRawResult } from "../agents/sandbox/docker.js"; +import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js"; import type { SandboxToolPolicy } from "../agents/sandbox/types.js"; import { loadWorkspaceSkillEntries } from "../agents/skills.js"; +import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js"; import { resolveToolProfilePolicy } from "../agents/tool-policy.js"; import { listAgentWorkspaceDirs } from "../agents/workspace-dirs.js"; import { formatCliCommand } from "../cli/command-format.js"; diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index 79a701c5489..bebcc44c0d0 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -1,16 +1,14 @@ -import { isToolAllowedByPolicies } from "../agents/pi-tools.policy.js"; -import { - resolveSandboxConfigForAgent, - resolveSandboxToolPolicyForAgent, -} from "../agents/sandbox.js"; +import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js"; import { isDangerousNetworkMode, normalizeNetworkMode } from "../agents/sandbox/network-mode.js"; /** * Synchronous security audit collector functions. * * These functions analyze config-based security properties without I/O. */ +import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js"; import type { SandboxToolPolicy } from "../agents/sandbox/types.js"; import { getBlockedBindReason } from "../agents/sandbox/validate-sandbox-security.js"; +import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js"; import { resolveToolProfilePolicy } from "../agents/tool-policy.js"; import { resolveBrowserConfig } from "../browser/config.js"; import { formatCliCommand } from "../cli/command-format.js"; @@ -21,7 +19,7 @@ import { } from "../config/model-input.js"; import type { AgentToolsConfig } from "../config/types.tools.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; -import { resolveAllowedAgentIds } from "../gateway/hooks.js"; +import { resolveAllowedAgentIds } from "../gateway/hooks-policy.js"; import { DEFAULT_DANGEROUS_NODE_COMMANDS, resolveNodeCommandAllowlist, From a2119efe1c2e08d5197d23925bdb43d04f04db5e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:43:21 -0700 Subject: [PATCH 16/23] Security: lazy-load deep skill audit helpers --- src/security/audit-extra.async.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/security/audit-extra.async.ts b/src/security/audit-extra.async.ts index 88df46bafa1..54f411eb73b 100644 --- a/src/security/audit-extra.async.ts +++ b/src/security/audit-extra.async.ts @@ -11,7 +11,6 @@ import { SANDBOX_BROWSER_SECURITY_HASH_EPOCH } from "../agents/sandbox/constants import { execDockerRaw, type ExecDockerRawResult } from "../agents/sandbox/docker.js"; import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js"; import type { SandboxToolPolicy } from "../agents/sandbox/types.js"; -import { loadWorkspaceSkillEntries } from "../agents/skills.js"; import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js"; import { resolveToolProfilePolicy } from "../agents/tool-policy.js"; import { listAgentWorkspaceDirs } from "../agents/workspace-dirs.js"; @@ -54,6 +53,12 @@ type ExecDockerRawFn = ( type CodeSafetySummaryCache = Map>; const MAX_WORKSPACE_SKILL_SCAN_FILES_PER_WORKSPACE = 2_000; const MAX_WORKSPACE_SKILL_ESCAPE_DETAIL_ROWS = 12; +let skillsModulePromise: Promise | undefined; + +function loadSkillsModule() { + skillsModulePromise ??= import("../agents/skills.js"); + return skillsModulePromise; +} // -------------------------------------------------------------------------- // Helpers @@ -1245,6 +1250,7 @@ export async function collectInstalledSkillsCodeSafetyFindings(params: { const pluginExtensionsDir = path.join(params.stateDir, "extensions"); const scannedSkillDirs = new Set(); const workspaceDirs = listAgentWorkspaceDirs(params.cfg); + const { loadWorkspaceSkillEntries } = await loadSkillsModule(); for (const workspaceDir of workspaceDirs) { const entries = loadWorkspaceSkillEntries(workspaceDir, { config: params.cfg }); From 5f42389d8d00d814e4c52ea1c5c37d34bcdeb99f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:45:36 -0700 Subject: [PATCH 17/23] Security: lazy-load audit config snapshot IO --- src/security/audit-extra.async.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/security/audit-extra.async.ts b/src/security/audit-extra.async.ts index 54f411eb73b..5e9c4036e09 100644 --- a/src/security/audit-extra.async.ts +++ b/src/security/audit-extra.async.ts @@ -18,7 +18,6 @@ import { formatCliCommand } from "../cli/command-format.js"; import { MANIFEST_KEY } from "../compat/legacy-names.js"; import { resolveNativeSkillsEnabled } from "../config/commands.js"; import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js"; -import { createConfigIO } from "../config/config.js"; import { collectIncludePathsRecursive } from "../config/includes-scan.js"; import { resolveOAuthDir } from "../config/paths.js"; import { hasConfiguredSecretInput } from "../config/types.secrets.js"; @@ -54,12 +53,18 @@ type CodeSafetySummaryCache = Map>; const MAX_WORKSPACE_SKILL_SCAN_FILES_PER_WORKSPACE = 2_000; const MAX_WORKSPACE_SKILL_ESCAPE_DETAIL_ROWS = 12; let skillsModulePromise: Promise | undefined; +let configModulePromise: Promise | undefined; function loadSkillsModule() { skillsModulePromise ??= import("../agents/skills.js"); return skillsModulePromise; } +function loadConfigModule() { + configModulePromise ??= import("../config/config.js"); + return configModulePromise; +} + // -------------------------------------------------------------------------- // Helpers // -------------------------------------------------------------------------- @@ -1133,6 +1138,7 @@ export async function readConfigSnapshotForAudit(params: { env: NodeJS.ProcessEnv; configPath: string; }): Promise { + const { createConfigIO } = await loadConfigModule(); return await createConfigIO({ env: params.env, configPath: params.configPath, From d47fc009dea25e99275333b8594d0626952ba3c1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:47:42 -0700 Subject: [PATCH 18/23] Config: keep native command defaults off heavy channel registry --- src/config/commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/commands.ts b/src/config/commands.ts index 4d174d7c396..29992b3a1cd 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -1,5 +1,5 @@ -import { normalizeChannelId } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.js"; +import { normalizeChannelId } from "../channels/registry.js"; import { isPlainObject } from "../infra/plain-object.js"; import type { CommandsConfig, NativeCommandsSetting } from "./types.js"; From c4b18ab3c9ae8c586bbfe02a279b13b2817cc3b8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:58:50 -0700 Subject: [PATCH 19/23] Status: split lightweight gateway agent list --- src/commands/status-all/agents.ts | 4 +- src/commands/status.agent-local.ts | 4 +- src/commands/status.summary.test.ts | 11 +++- src/commands/status.summary.ts | 9 +-- src/commands/status.test.ts | 17 ++++-- src/gateway/agent-list.ts | 88 +++++++++++++++++++++++++++++ 6 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 src/gateway/agent-list.ts diff --git a/src/commands/status-all/agents.ts b/src/commands/status-all/agents.ts index caf1ae03ed2..e8d7c485fe5 100644 --- a/src/commands/status-all/agents.ts +++ b/src/commands/status-all/agents.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import type { OpenClawConfig } from "../../config/config.js"; import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; -import { listAgentsForGateway } from "../../gateway/session-utils.js"; +import { listGatewayAgentsBasic } from "../../gateway/agent-list.js"; async function fileExists(p: string): Promise { try { @@ -15,7 +15,7 @@ async function fileExists(p: string): Promise { } export async function getAgentLocalStatuses(cfg: OpenClawConfig) { - const agentList = listAgentsForGateway(cfg); + const agentList = listGatewayAgentsBasic(cfg); const now = Date.now(); const agents = await Promise.all( diff --git a/src/commands/status.agent-local.ts b/src/commands/status.agent-local.ts index 5c57036eb97..ce17f9ab94f 100644 --- a/src/commands/status.agent-local.ts +++ b/src/commands/status.agent-local.ts @@ -4,7 +4,7 @@ import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; -import { listAgentsForGateway } from "../gateway/session-utils.js"; +import { listGatewayAgentsBasic } from "../gateway/agent-list.js"; export type AgentLocalStatus = { id: string; @@ -36,7 +36,7 @@ async function fileExists(p: string): Promise { export async function getAgentLocalStatuses( cfg: OpenClawConfig = loadConfig(), ): Promise { - const agentList = listAgentsForGateway(cfg); + const agentList = listGatewayAgentsBasic(cfg); const now = Date.now(); const statuses: AgentLocalStatus[] = []; diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index 2045c380e1b..12ce55844c3 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -32,12 +32,15 @@ vi.mock("../config/sessions.js", () => ({ resolveStorePath: vi.fn(() => "/tmp/sessions.json"), })); -vi.mock("../gateway/session-utils.js", () => ({ - classifySessionKey: vi.fn(() => "direct"), - listAgentsForGateway: vi.fn(() => ({ +vi.mock("../gateway/agent-list.js", () => ({ + listGatewayAgentsBasic: vi.fn(() => ({ defaultId: "main", agents: [{ id: "main" }], })), +})); + +vi.mock("../gateway/session-utils.js", () => ({ + classifySessionKey: vi.fn(() => "direct"), resolveSessionModelRef: vi.fn(() => ({ provider: "openai", model: "gpt-5.2", @@ -61,6 +64,8 @@ vi.mock("../infra/system-events.js", () => ({ })); vi.mock("../routing/session-key.js", () => ({ + normalizeAgentId: vi.fn((value: string) => value), + normalizeMainKey: vi.fn((value?: string) => value ?? "main"), parseAgentSessionKey: vi.fn(() => null), })); diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index 6de3b282648..3d151c64772 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -11,11 +11,8 @@ import { resolveStorePath, type SessionEntry, } from "../config/sessions.js"; -import { - classifySessionKey, - listAgentsForGateway, - resolveSessionModelRef, -} from "../gateway/session-utils.js"; +import { listGatewayAgentsBasic } from "../gateway/agent-list.js"; +import { classifySessionKey, resolveSessionModelRef } from "../gateway/session-utils.js"; import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js"; import { peekSystemEvents } from "../infra/system-events.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; @@ -107,7 +104,7 @@ export async function getStatusSummary( resolveLinkChannelContext(cfg), ) : null; - const agentList = listAgentsForGateway(cfg); + const agentList = listGatewayAgentsBasic(cfg); const heartbeatAgents: HeartbeatStatus[] = agentList.agents.map((agent) => { const summary = resolveHeartbeatSummaryForAgent(cfg, agent.id); return { diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index f3dfd37064a..3e68d55ced2 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -168,7 +168,7 @@ const mocks = vi.hoisted(() => ({ configSnapshot: null, }), callGateway: vi.fn().mockResolvedValue({}), - listAgentsForGateway: vi.fn().mockReturnValue({ + listGatewayAgentsBasic: vi.fn().mockReturnValue({ defaultId: "main", mainKey: "agent:main:main", scope: "per-sender", @@ -299,11 +299,18 @@ vi.mock("../gateway/call.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, callGateway: mocks.callGateway }; }); +vi.mock("../gateway/agent-list.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listGatewayAgentsBasic: mocks.listGatewayAgentsBasic, + }; +}); + vi.mock("../gateway/session-utils.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - listAgentsForGateway: mocks.listAgentsForGateway, }; }); vi.mock("../infra/openclaw-root.js", () => ({ @@ -608,11 +615,11 @@ describe("statusCommand", () => { }); it("includes sessions across agents in JSON output", async () => { - const originalAgents = mocks.listAgentsForGateway.getMockImplementation(); + const originalAgents = mocks.listGatewayAgentsBasic.getMockImplementation(); const originalResolveStorePath = mocks.resolveStorePath.getMockImplementation(); const originalLoadSessionStore = mocks.loadSessionStore.getMockImplementation(); - mocks.listAgentsForGateway.mockReturnValue({ + mocks.listGatewayAgentsBasic.mockReturnValue({ defaultId: "main", mainKey: "agent:main:main", scope: "per-sender", @@ -651,7 +658,7 @@ describe("statusCommand", () => { ).toBe(true); if (originalAgents) { - mocks.listAgentsForGateway.mockImplementation(originalAgents); + mocks.listGatewayAgentsBasic.mockImplementation(originalAgents); } if (originalResolveStorePath) { mocks.resolveStorePath.mockImplementation(originalResolveStorePath); diff --git a/src/gateway/agent-list.ts b/src/gateway/agent-list.ts new file mode 100644 index 00000000000..d14cdf0c534 --- /dev/null +++ b/src/gateway/agent-list.ts @@ -0,0 +1,88 @@ +import fs from "node:fs"; +import path from "node:path"; +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; +import type { SessionScope } from "../config/sessions.js"; +import { normalizeAgentId, normalizeMainKey } from "../routing/session-key.js"; + +export type GatewayAgentListRow = { + id: string; + name?: string; +}; + +function listExistingAgentIdsFromDisk(): string[] { + const root = resolveStateDir(); + const agentsDir = path.join(root, "agents"); + try { + const entries = fs.readdirSync(agentsDir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => normalizeAgentId(entry.name)) + .filter(Boolean); + } catch { + return []; + } +} + +function listConfiguredAgentIds(cfg: OpenClawConfig): string[] { + const ids = new Set(); + const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg)); + ids.add(defaultId); + + for (const entry of cfg.agents?.list ?? []) { + if (entry?.id) { + ids.add(normalizeAgentId(entry.id)); + } + } + + for (const id of listExistingAgentIdsFromDisk()) { + ids.add(id); + } + + const sorted = Array.from(ids).filter(Boolean); + sorted.sort((a, b) => a.localeCompare(b)); + return sorted.includes(defaultId) + ? [defaultId, ...sorted.filter((id) => id !== defaultId)] + : sorted; +} + +export function listGatewayAgentsBasic(cfg: OpenClawConfig): { + defaultId: string; + mainKey: string; + scope: SessionScope; + agents: GatewayAgentListRow[]; +} { + const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg)); + const mainKey = normalizeMainKey(cfg.session?.mainKey); + const scope = cfg.session?.scope ?? "per-sender"; + const configuredById = new Map(); + for (const entry of cfg.agents?.list ?? []) { + if (!entry?.id) { + continue; + } + configuredById.set(normalizeAgentId(entry.id), { + name: typeof entry.name === "string" && entry.name.trim() ? entry.name.trim() : undefined, + }); + } + const explicitIds = new Set( + (cfg.agents?.list ?? []) + .map((entry) => (entry?.id ? normalizeAgentId(entry.id) : "")) + .filter(Boolean), + ); + const allowedIds = explicitIds.size > 0 ? new Set([...explicitIds, defaultId]) : null; + let agentIds = listConfiguredAgentIds(cfg).filter((id) => + allowedIds ? allowedIds.has(id) : true, + ); + if (mainKey && !agentIds.includes(mainKey) && (!allowedIds || allowedIds.has(mainKey))) { + agentIds = [...agentIds, mainKey]; + } + const agents = agentIds.map((id) => { + const meta = configuredById.get(id); + return { + id, + name: meta?.name, + }; + }); + return { defaultId, mainKey, scope, agents }; +} From ddd34b6cc3b90e97c3af745b4ef136684d2ab798 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 23:00:16 -0700 Subject: [PATCH 20/23] refactor(plugins): simplify provider auth choice metadata --- docs/concepts/model-providers.md | 4 +- docs/tools/plugin.md | 8 +- extensions/anthropic/index.ts | 19 ++- extensions/openai/openai-codex-provider.ts | 2 + src/cli/models-cli.test.ts | 17 ++- src/cli/models-cli.ts | 7 +- .../auth-choice.apply.plugin-provider.ts | 14 +- src/commands/auth-choice.apply.ts | 11 +- .../auth-choice.preferred-provider.test.ts | 50 +++++++ .../auth-choice.preferred-provider.ts | 30 +++-- ...re.gateway-auth.prompt-auth-config.test.ts | 39 ++++++ src/commands/configure.gateway-auth.ts | 48 +++++-- src/commands/doctor-auth.ts | 41 +++--- src/commands/models/auth.ts | 8 +- src/plugins/provider-validation.test.ts | 25 +++- src/plugins/provider-validation.ts | 124 +++++++++++++----- src/plugins/provider-wizard.test.ts | 36 +++++ src/plugins/provider-wizard.ts | 10 +- src/plugins/types.ts | 20 +++ 19 files changed, 415 insertions(+), 98 deletions(-) create mode 100644 src/commands/auth-choice.preferred-provider.test.ts diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index fc0656c0dd4..6adbb5d0f26 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -42,8 +42,8 @@ Typical split: - `auth[].run` / `auth[].runNonInteractive`: provider owns onboarding/login flows for `openclaw onboard`, `openclaw models auth`, and headless setup -- `wizard.onboarding` / `wizard.modelPicker`: provider owns auth-choice labels, - hints, and setup entries in onboarding/model pickers +- `wizard.setup` / `wizard.modelPicker`: provider owns auth-choice labels, + legacy aliases, onboarding allowlist hints, and setup entries in onboarding/model pickers - `catalog`: provider appears in `models.providers` - `resolveDynamicModel`: provider accepts model ids not present in the local static catalog yet diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index c14f3c39f56..ec4084eeca6 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1275,6 +1275,7 @@ errors instead. - `groupLabel`: group label - `groupHint`: group hint - `methodId`: auth method to run +- `modelAllowlist`: optional post-auth allowlist policy (`allowedKeys`, `initialSelections`, `message`) `wizard.modelPicker` controls how a provider appears as a "set this up now" entry in model selection: @@ -1435,8 +1436,13 @@ Notes: for headless onboarding. - Return `configPatch` when you need to add default models or provider config. - Return `defaultModel` so `--set-default` can update agent defaults. -- `wizard.setup` adds a provider choice to `openclaw onboard`. +- `wizard.setup` adds a provider choice to onboarding surfaces such as + `openclaw onboard` / `openclaw setup --wizard`. +- `wizard.setup.modelAllowlist` lets the provider narrow the follow-up model + allowlist prompt during onboarding/configure. - `wizard.modelPicker` adds a “setup this provider” entry to the model picker. +- `deprecatedProfileIds` lets the provider own `openclaw doctor` cleanup for + retired auth-profile ids. - `discovery.run` returns either `{ provider }` for the plugin’s own provider id or `{ providers }` for multi-provider discovery. - `discovery.order` controls when the provider runs relative to built-in diff --git a/extensions/anthropic/index.ts b/extensions/anthropic/index.ts index 13758e7de46..a2491dfbd87 100644 --- a/extensions/anthropic/index.ts +++ b/extensions/anthropic/index.ts @@ -5,7 +5,11 @@ import { type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; -import { listProfilesForProvider, upsertAuthProfile } from "../../src/agents/auth-profiles.js"; +import { + CLAUDE_CLI_PROFILE_ID, + listProfilesForProvider, + upsertAuthProfile, +} from "../../src/agents/auth-profiles.js"; import { suggestOAuthProfileIdForLegacyDefault } from "../../src/agents/auth-profiles/repair.js"; import type { AuthProfileStore } from "../../src/agents/auth-profiles/types.js"; import { normalizeModelCompat } from "../../src/agents/model-compat.js"; @@ -38,6 +42,13 @@ const ANTHROPIC_MODERN_MODEL_PREFIXES = [ "claude-sonnet-4-5", "claude-haiku-4-5", ] as const; +const ANTHROPIC_OAUTH_ALLOWLIST = [ + "anthropic/claude-sonnet-4-6", + "anthropic/claude-opus-4-6", + "anthropic/claude-opus-4-5", + "anthropic/claude-sonnet-4-5", + "anthropic/claude-haiku-4-5", +] as const; function cloneFirstTemplateModel(params: { modelId: string; @@ -309,6 +320,7 @@ const anthropicPlugin = { label: "Anthropic", docsPath: "/providers/models", envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], + deprecatedProfileIds: [CLAUDE_CLI_PROFILE_ID], auth: [ { id: "setup-token", @@ -322,6 +334,11 @@ const anthropicPlugin = { groupId: "anthropic", groupLabel: "Anthropic", groupHint: "setup-token + API key", + modelAllowlist: { + allowedKeys: [...ANTHROPIC_OAUTH_ALLOWLIST], + initialSelections: ["anthropic/claude-sonnet-4-6"], + message: "Anthropic OAuth models", + }, }, run: async (ctx: ProviderAuthContext) => await runAnthropicSetupToken(ctx), runNonInteractive: async (ctx) => diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index 17ee1348de2..c0ae2c12210 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -4,6 +4,7 @@ import type { ProviderResolveDynamicModelContext, ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; +import { CODEX_CLI_PROFILE_ID } from "../../src/agents/auth-profiles.js"; import { listProfilesForProvider } from "../../src/agents/auth-profiles/profiles.js"; import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js"; import type { OAuthCredential } from "../../src/agents/auth-profiles/types.js"; @@ -194,6 +195,7 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin { id: PROVIDER_ID, label: "OpenAI Codex", docsPath: "/providers/models", + deprecatedProfileIds: [CODEX_CLI_PROFILE_ID], auth: [ { id: "oauth", diff --git a/src/cli/models-cli.test.ts b/src/cli/models-cli.test.ts index 7386988a1f0..208b74fd09d 100644 --- a/src/cli/models-cli.test.ts +++ b/src/cli/models-cli.test.ts @@ -2,18 +2,17 @@ import { Command } from "commander"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { runRegisteredCli } from "../test-utils/command-runner.js"; -const githubCopilotLoginCommand = vi.fn(); const modelsStatusCommand = vi.fn().mockResolvedValue(undefined); const noopAsync = vi.fn(async () => undefined); +const modelsAuthLoginCommand = vi.fn().mockResolvedValue(undefined); vi.mock("../commands/models.js", () => ({ - githubCopilotLoginCommand, modelsStatusCommand, modelsAliasesAddCommand: noopAsync, modelsAliasesListCommand: noopAsync, modelsAliasesRemoveCommand: noopAsync, modelsAuthAddCommand: noopAsync, - modelsAuthLoginCommand: noopAsync, + modelsAuthLoginCommand, modelsAuthOrderClearCommand: noopAsync, modelsAuthOrderGetCommand: noopAsync, modelsAuthOrderSetCommand: noopAsync, @@ -42,7 +41,7 @@ describe("models cli", () => { }); beforeEach(() => { - githubCopilotLoginCommand.mockClear(); + modelsAuthLoginCommand.mockClear(); modelsStatusCommand.mockClear(); }); @@ -74,9 +73,13 @@ describe("models cli", () => { from: "user", }); - expect(githubCopilotLoginCommand).toHaveBeenCalledTimes(1); - expect(githubCopilotLoginCommand).toHaveBeenCalledWith( - expect.objectContaining({ yes: true }), + expect(modelsAuthLoginCommand).toHaveBeenCalledTimes(1); + expect(modelsAuthLoginCommand).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "github-copilot", + method: "device", + yes: true, + }), expect.any(Object), ); }); diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index d3be2d6c131..f3391c2796e 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -1,6 +1,5 @@ import type { Command } from "commander"; import { - githubCopilotLoginCommand, modelsAliasesAddCommand, modelsAliasesListCommand, modelsAliasesRemoveCommand, @@ -364,13 +363,13 @@ export function registerModelsCli(program: Command) { auth .command("login-github-copilot") .description("Login to GitHub Copilot via GitHub device flow (TTY required)") - .option("--profile-id ", "Auth profile id (default: github-copilot:github)") .option("--yes", "Overwrite existing profile without prompting", false) .action(async (opts) => { await runModelsCommand(async () => { - await githubCopilotLoginCommand( + await modelsAuthLoginCommand( { - profileId: opts.profileId as string | undefined, + provider: "github-copilot", + method: "device", yes: Boolean(opts.yes), }, defaultRuntime, diff --git a/src/commands/auth-choice.apply.plugin-provider.ts b/src/commands/auth-choice.apply.plugin-provider.ts index 5f4893b249c..76994d27b32 100644 --- a/src/commands/auth-choice.apply.plugin-provider.ts +++ b/src/commands/auth-choice.apply.plugin-provider.ts @@ -117,7 +117,12 @@ export async function applyAuthChoiceLoadedPluginProvider( resolveAgentWorkspaceDir(params.config, agentId) ?? resolveDefaultAgentWorkspaceDir(); const { resolvePluginProviders, resolveProviderPluginChoice, runProviderModelSelectedHook } = await loadPluginProviderRuntime(); - const providers = resolvePluginProviders({ config: params.config, workspaceDir }); + const providers = resolvePluginProviders({ + config: params.config, + workspaceDir, + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + }); const resolved = resolveProviderPluginChoice({ providers, choice: params.authChoice, @@ -190,7 +195,12 @@ export async function applyAuthChoicePluginProvider( const { resolvePluginProviders, runProviderModelSelectedHook } = await loadPluginProviderRuntime(); - const providers = resolvePluginProviders({ config: nextConfig, workspaceDir }); + const providers = resolvePluginProviders({ + config: nextConfig, + workspaceDir, + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + }); const provider = resolveProviderMatch(providers, options.providerId); if (!provider) { await params.prompter.note( diff --git a/src/commands/auth-choice.apply.ts b/src/commands/auth-choice.apply.ts index 798d8991199..bafa9122e25 100644 --- a/src/commands/auth-choice.apply.ts +++ b/src/commands/auth-choice.apply.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; +import { normalizeLegacyOnboardAuthChoice } from "./auth-choice-legacy.js"; import { applyAuthChoiceAnthropic } from "./auth-choice.apply.anthropic.js"; import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.js"; import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js"; @@ -28,6 +29,12 @@ export type ApplyAuthChoiceResult = { export async function applyAuthChoice( params: ApplyAuthChoiceParams, ): Promise { + const normalizedAuthChoice = + normalizeLegacyOnboardAuthChoice(params.authChoice) ?? params.authChoice; + const normalizedParams = + normalizedAuthChoice === params.authChoice + ? params + : { ...params, authChoice: normalizedAuthChoice }; const handlers: Array<(p: ApplyAuthChoiceParams) => Promise> = [ applyAuthChoiceLoadedPluginProvider, applyAuthChoiceAnthropic, @@ -38,11 +45,11 @@ export async function applyAuthChoice( ]; for (const handler of handlers) { - const result = await handler(params); + const result = await handler(normalizedParams); if (result) { return result; } } - return { config: params.config }; + return { config: normalizedParams.config }; } diff --git a/src/commands/auth-choice.preferred-provider.test.ts b/src/commands/auth-choice.preferred-provider.test.ts new file mode 100644 index 00000000000..6f84763a308 --- /dev/null +++ b/src/commands/auth-choice.preferred-provider.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const resolveProviderPluginChoice = vi.hoisted(() => vi.fn()); +const resolvePluginProviders = vi.hoisted(() => vi.fn(() => [])); + +vi.mock("../plugins/provider-wizard.js", () => ({ + resolveProviderPluginChoice, +})); + +vi.mock("../plugins/providers.js", () => ({ + resolvePluginProviders, +})); + +import { resolvePreferredProviderForAuthChoice } from "./auth-choice.preferred-provider.js"; + +describe("resolvePreferredProviderForAuthChoice", () => { + beforeEach(() => { + vi.clearAllMocks(); + resolvePluginProviders.mockReturnValue([]); + resolveProviderPluginChoice.mockReturnValue(null); + }); + + it("normalizes legacy auth choices before plugin lookup", async () => { + resolveProviderPluginChoice.mockReturnValue({ + provider: { id: "anthropic", label: "Anthropic", auth: [] }, + method: { id: "setup-token", label: "setup-token", kind: "token" }, + }); + + await expect(resolvePreferredProviderForAuthChoice({ choice: "claude-cli" })).resolves.toBe( + "anthropic", + ); + expect(resolveProviderPluginChoice).toHaveBeenCalledWith( + expect.objectContaining({ + choice: "setup-token", + }), + ); + expect(resolvePluginProviders).toHaveBeenCalledWith( + expect.objectContaining({ + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + }), + ); + }); + + it("falls back to static core choices when no provider plugin claims the choice", async () => { + await expect(resolvePreferredProviderForAuthChoice({ choice: "chutes" })).resolves.toBe( + "chutes", + ); + }); +}); diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index 49251a88f87..a7faad5d3a4 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -1,15 +1,12 @@ import type { OpenClawConfig } from "../config/config.js"; +import { normalizeLegacyOnboardAuthChoice } from "./auth-choice-legacy.js"; import type { AuthChoice } from "./onboard-types.js"; const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { - oauth: "anthropic", - "setup-token": "anthropic", - "claude-cli": "anthropic", + chutes: "chutes", token: "anthropic", apiKey: "anthropic", "openai-codex": "openai-codex", - "codex-cli": "openai-codex", - chutes: "chutes", "openai-api-key": "openai", "openrouter-api-key": "openrouter", "kilocode-api-key": "kilocode", @@ -57,11 +54,7 @@ export async function resolvePreferredProviderForAuthChoice(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }): Promise { - const preferred = PREFERRED_PROVIDER_BY_AUTH_CHOICE[params.choice]; - if (preferred) { - return preferred; - } - + const choice = normalizeLegacyOnboardAuthChoice(params.choice) ?? params.choice; const [{ resolveProviderPluginChoice }, { resolvePluginProviders }] = await Promise.all([ import("../plugins/provider-wizard.js"), import("../plugins/providers.js"), @@ -70,9 +63,20 @@ export async function resolvePreferredProviderForAuthChoice(params: { config: params.config, workspaceDir: params.workspaceDir, env: params.env, + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, }); - return resolveProviderPluginChoice({ + const pluginResolved = resolveProviderPluginChoice({ providers, - choice: params.choice, - })?.provider.id; + choice, + }); + if (pluginResolved) { + return pluginResolved.provider.id; + } + + const preferred = PREFERRED_PROVIDER_BY_AUTH_CHOICE[choice]; + if (preferred) { + return preferred; + } + return undefined; } 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 0657a77b3e1..b6ba81a432e 100644 --- a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts +++ b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts @@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({ promptModelAllowlist: vi.fn(), promptDefaultModel: vi.fn(), promptCustomApiConfig: vi.fn(), + resolvePluginProviders: vi.fn(() => []), + resolveProviderPluginChoice: vi.fn<() => unknown>(() => null), })); vi.mock("../agents/auth-profiles.js", () => ({ @@ -39,6 +41,14 @@ vi.mock("./onboard-custom.js", () => ({ promptCustomApiConfig: mocks.promptCustomApiConfig, })); +vi.mock("../plugins/providers.js", () => ({ + resolvePluginProviders: mocks.resolvePluginProviders, +})); + +vi.mock("../plugins/provider-wizard.js", () => ({ + resolveProviderPluginChoice: mocks.resolveProviderPluginChoice, +})); + import { promptAuthConfig } from "./configure.gateway-auth.js"; function makeRuntime(): RuntimeEnv { @@ -94,6 +104,8 @@ async function runPromptAuthConfigWithAllowlist(includeMinimaxProvider = false) mocks.promptModelAllowlist.mockResolvedValue({ models: ["kilocode/kilo/auto"], }); + mocks.resolvePluginProviders.mockReturnValue([]); + mocks.resolveProviderPluginChoice.mockReturnValue(null); return promptAuthConfig({}, makeRuntime(), noopPrompter); } @@ -118,4 +130,31 @@ describe("promptAuthConfig", () => { "MiniMax-M2.5", ]); }); + + it("uses plugin-owned allowlist metadata for provider auth choices", async () => { + mocks.promptAuthChoiceGrouped.mockResolvedValue("token"); + mocks.applyAuthChoice.mockResolvedValue({ config: {} }); + mocks.promptModelAllowlist.mockResolvedValue({ models: undefined }); + mocks.resolveProviderPluginChoice.mockReturnValue({ + provider: { id: "anthropic", label: "Anthropic", auth: [] }, + method: { id: "setup-token", label: "setup-token", kind: "token" }, + wizard: { + modelAllowlist: { + allowedKeys: ["anthropic/claude-sonnet-4-6"], + initialSelections: ["anthropic/claude-sonnet-4-6"], + message: "Anthropic OAuth models", + }, + }, + }); + + await promptAuthConfig({}, makeRuntime(), noopPrompter); + + expect(mocks.promptModelAllowlist).toHaveBeenCalledWith( + expect.objectContaining({ + allowedKeys: ["anthropic/claude-sonnet-4-6"], + initialSelections: ["anthropic/claude-sonnet-4-6"], + message: "Anthropic OAuth models", + }), + ); + }); }); diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index ca56ee25275..8963557e80a 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -2,6 +2,8 @@ import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; import type { OpenClawConfig, GatewayAuthConfig } from "../config/config.js"; import { isSecretRef, type SecretInput } from "../config/types.secrets.js"; +import { resolveProviderPluginChoice } from "../plugins/provider-wizard.js"; +import { resolvePluginProviders } from "../plugins/providers.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { promptAuthChoiceGrouped } from "./auth-choice-prompt.js"; @@ -30,13 +32,30 @@ function sanitizeTokenValue(value: unknown): string | undefined { return trimmed; } -const ANTHROPIC_OAUTH_MODEL_KEYS = [ - "anthropic/claude-sonnet-4-6", - "anthropic/claude-opus-4-6", - "anthropic/claude-opus-4-5", - "anthropic/claude-sonnet-4-5", - "anthropic/claude-haiku-4-5", -]; +function resolveProviderChoiceModelAllowlist(params: { + authChoice: string; + config: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): + | { + allowedKeys?: string[]; + initialSelections?: string[]; + message?: string; + } + | undefined { + const providers = resolvePluginProviders({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + }); + return resolveProviderPluginChoice({ + providers, + choice: params.authChoice, + })?.wizard?.modelAllowlist; +} export function buildGatewayAuthConfig(params: { existing?: GatewayAuthConfig; @@ -125,16 +144,19 @@ export async function promptAuthConfig( } } - const anthropicOAuth = - authChoice === "setup-token" || authChoice === "token" || authChoice === "oauth"; - if (authChoice !== "custom-api-key") { + const modelAllowlist = resolveProviderChoiceModelAllowlist({ + authChoice, + config: next, + workspaceDir: resolveDefaultAgentWorkspaceDir(), + env: process.env, + }); const allowlistSelection = await promptModelAllowlist({ config: next, prompter, - allowedKeys: anthropicOAuth ? ANTHROPIC_OAUTH_MODEL_KEYS : undefined, - initialSelections: anthropicOAuth ? ["anthropic/claude-sonnet-4-6"] : undefined, - message: anthropicOAuth ? "Anthropic OAuth models" : undefined, + allowedKeys: modelAllowlist?.allowedKeys, + initialSelections: modelAllowlist?.initialSelections, + message: modelAllowlist?.message, }); if (allowlistSelection.models) { next = applyModelAllowlist(next, allowlistSelection.models); diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index cf8267cebff..1f46ef28ba1 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -15,6 +15,7 @@ import { import { updateAuthProfileStoreWithLock } from "../agents/auth-profiles/store.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/config.js"; +import { resolvePluginProviders } from "../plugins/providers.js"; import { note } from "../terminal/note.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; import { @@ -119,30 +120,36 @@ export async function maybeRemoveDeprecatedCliAuthProfiles( prompter: DoctorPrompter, ): Promise { const store = ensureAuthProfileStore(undefined, { allowKeychainPrompt: false }); - const deprecated = new Set(); - if (store.profiles[CLAUDE_CLI_PROFILE_ID] || cfg.auth?.profiles?.[CLAUDE_CLI_PROFILE_ID]) { - deprecated.add(CLAUDE_CLI_PROFILE_ID); - } - if (store.profiles[CODEX_CLI_PROFILE_ID] || cfg.auth?.profiles?.[CODEX_CLI_PROFILE_ID]) { - deprecated.add(CODEX_CLI_PROFILE_ID); - } + const providers = resolvePluginProviders({ + config: cfg, + env: process.env, + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + }); + const deprecatedEntries = providers.flatMap((provider) => + (provider.deprecatedProfileIds ?? []) + .filter((profileId) => store.profiles[profileId] || cfg.auth?.profiles?.[profileId]) + .map((profileId) => ({ + profileId, + providerId: provider.id, + providerLabel: provider.label, + })), + ); + const deprecated = new Set(deprecatedEntries.map((entry) => entry.profileId)); if (deprecated.size === 0) { return cfg; } const lines = ["Deprecated external CLI auth profiles detected (no longer supported):"]; - if (deprecated.has(CLAUDE_CLI_PROFILE_ID)) { + for (const entry of deprecatedEntries) { const authCommand = - resolveProviderAuthLoginCommand({ provider: "anthropic" }) ?? - formatCliCommand("openclaw configure"); - lines.push(`- ${CLAUDE_CLI_PROFILE_ID} (Anthropic): use ${authCommand}`); - } - if (deprecated.has(CODEX_CLI_PROFILE_ID)) { - const authCommand = - resolveProviderAuthLoginCommand({ provider: "openai-codex" }) ?? - formatCliCommand("openclaw configure"); - lines.push(`- ${CODEX_CLI_PROFILE_ID} (OpenAI Codex): use ${authCommand}`); + resolveProviderAuthLoginCommand({ + provider: entry.providerId, + config: cfg, + env: process.env, + }) ?? formatCliCommand("openclaw configure"); + lines.push(`- ${entry.profileId} (${entry.providerLabel}): use ${authCommand}`); } note(lines.join("\n"), "Auth profiles"); diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 1ea838fdb27..6001ede2ea4 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -106,7 +106,12 @@ async function resolveModelsAuthContext(): Promise { const agentDir = resolveAgentDir(config, defaultAgentId); const workspaceDir = resolveAgentWorkspaceDir(config, defaultAgentId) ?? resolveDefaultAgentWorkspaceDir(); - const providers = resolvePluginProviders({ config, workspaceDir }); + const providers = resolvePluginProviders({ + config, + workspaceDir, + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + }); return { config, agentDir, workspaceDir, providers }; } @@ -490,6 +495,7 @@ type LoginOptions = { provider?: string; method?: string; setDefault?: boolean; + yes?: boolean; }; /** diff --git a/src/plugins/provider-validation.test.ts b/src/plugins/provider-validation.test.ts index f0208aa1d2a..fe934aa6578 100644 --- a/src/plugins/provider-validation.test.ts +++ b/src/plugins/provider-validation.test.ts @@ -32,12 +32,21 @@ describe("normalizeRegisteredProvider", () => { id: " demo ", label: " Demo Provider ", aliases: [" alias-one ", "alias-one", ""], + deprecatedProfileIds: [" demo:legacy ", "demo:legacy", ""], envVars: [" DEMO_API_KEY ", "DEMO_API_KEY"], auth: [ { id: " primary ", label: " Primary ", kind: "custom", + wizard: { + choiceId: " demo-primary ", + modelAllowlist: { + allowedKeys: [" demo/model ", "demo/model"], + initialSelections: [" demo/model "], + message: " Demo models ", + }, + }, run: async () => ({ profiles: [] }), }, { @@ -66,8 +75,22 @@ describe("normalizeRegisteredProvider", () => { id: "demo", label: "Demo Provider", aliases: ["alias-one"], + deprecatedProfileIds: ["demo:legacy"], envVars: ["DEMO_API_KEY"], - auth: [{ id: "primary", label: "Primary" }], + auth: [ + { + id: "primary", + label: "Primary", + wizard: { + choiceId: "demo-primary", + modelAllowlist: { + allowedKeys: ["demo/model"], + initialSelections: ["demo/model"], + message: "Demo models", + }, + }, + }, + ], wizard: { setup: { choiceId: "demo-choice", diff --git a/src/plugins/provider-validation.ts b/src/plugins/provider-validation.ts index 172fefc1777..f53abc8bd6d 100644 --- a/src/plugins/provider-validation.ts +++ b/src/plugins/provider-validation.ts @@ -27,6 +27,80 @@ function normalizeTextList(values: string[] | undefined): string[] | undefined { return normalized.length > 0 ? normalized : undefined; } +function normalizeProviderWizardSetup(params: { + providerId: string; + pluginId: string; + source: string; + auth: ProviderAuthMethod[]; + setup: NonNullable["setup"]; + pushDiagnostic: (diag: PluginDiagnostic) => void; +}): NonNullable["setup"] { + const hasAuthMethods = params.auth.length > 0; + if (!params.setup) { + return undefined; + } + if (!hasAuthMethods) { + pushProviderDiagnostic({ + level: "warn", + pluginId: params.pluginId, + source: params.source, + message: `provider "${params.providerId}" setup metadata ignored because it has no auth methods`, + pushDiagnostic: params.pushDiagnostic, + }); + return undefined; + } + const methodId = normalizeText(params.setup.methodId); + if (methodId && !params.auth.some((method) => method.id === methodId)) { + pushProviderDiagnostic({ + level: "warn", + pluginId: params.pluginId, + source: params.source, + message: `provider "${params.providerId}" setup method "${methodId}" not found; falling back to available methods`, + pushDiagnostic: params.pushDiagnostic, + }); + } + return { + ...(normalizeText(params.setup.choiceId) + ? { choiceId: normalizeText(params.setup.choiceId) } + : {}), + ...(normalizeText(params.setup.choiceLabel) + ? { choiceLabel: normalizeText(params.setup.choiceLabel) } + : {}), + ...(normalizeText(params.setup.choiceHint) + ? { choiceHint: normalizeText(params.setup.choiceHint) } + : {}), + ...(normalizeText(params.setup.groupId) + ? { groupId: normalizeText(params.setup.groupId) } + : {}), + ...(normalizeText(params.setup.groupLabel) + ? { groupLabel: normalizeText(params.setup.groupLabel) } + : {}), + ...(normalizeText(params.setup.groupHint) + ? { groupHint: normalizeText(params.setup.groupHint) } + : {}), + ...(methodId && params.auth.some((method) => method.id === methodId) ? { methodId } : {}), + ...(params.setup.modelAllowlist + ? { + modelAllowlist: { + ...(normalizeTextList(params.setup.modelAllowlist.allowedKeys) + ? { allowedKeys: normalizeTextList(params.setup.modelAllowlist.allowedKeys) } + : {}), + ...(normalizeTextList(params.setup.modelAllowlist.initialSelections) + ? { + initialSelections: normalizeTextList( + params.setup.modelAllowlist.initialSelections, + ), + } + : {}), + ...(normalizeText(params.setup.modelAllowlist.message) + ? { message: normalizeText(params.setup.modelAllowlist.message) } + : {}), + }, + } + : {}), + }; +} + function normalizeProviderAuthMethods(params: { providerId: string; pluginId: string; @@ -60,11 +134,20 @@ function normalizeProviderAuthMethods(params: { continue; } seenMethodIds.add(methodId); + const wizard = normalizeProviderWizardSetup({ + providerId: params.providerId, + pluginId: params.pluginId, + source: params.source, + auth: [{ ...method, id: methodId }], + setup: method.wizard, + pushDiagnostic: params.pushDiagnostic, + }); normalized.push({ ...method, id: methodId, label: normalizeText(method.label) ?? methodId, ...(normalizeText(method.hint) ? { hint: normalizeText(method.hint) } : {}), + ...(wizard ? { wizard } : {}), }); } @@ -92,37 +175,14 @@ function normalizeProviderWizard(params: { if (!setup) { return undefined; } - if (!hasAuthMethods) { - pushProviderDiagnostic({ - level: "warn", - pluginId: params.pluginId, - source: params.source, - message: `provider "${params.providerId}" setup metadata ignored because it has no auth methods`, - pushDiagnostic: params.pushDiagnostic, - }); - return undefined; - } - const methodId = normalizeText(setup.methodId); - if (methodId && !hasMethod(methodId)) { - pushProviderDiagnostic({ - level: "warn", - pluginId: params.pluginId, - source: params.source, - message: `provider "${params.providerId}" setup method "${methodId}" not found; falling back to available methods`, - pushDiagnostic: params.pushDiagnostic, - }); - } - return { - ...(normalizeText(setup.choiceId) ? { choiceId: normalizeText(setup.choiceId) } : {}), - ...(normalizeText(setup.choiceLabel) - ? { choiceLabel: normalizeText(setup.choiceLabel) } - : {}), - ...(normalizeText(setup.choiceHint) ? { choiceHint: normalizeText(setup.choiceHint) } : {}), - ...(normalizeText(setup.groupId) ? { groupId: normalizeText(setup.groupId) } : {}), - ...(normalizeText(setup.groupLabel) ? { groupLabel: normalizeText(setup.groupLabel) } : {}), - ...(normalizeText(setup.groupHint) ? { groupHint: normalizeText(setup.groupHint) } : {}), - ...(methodId && hasMethod(methodId) ? { methodId } : {}), - }; + return normalizeProviderWizardSetup({ + providerId: params.providerId, + pluginId: params.pluginId, + source: params.source, + auth: params.auth, + setup, + pushDiagnostic: params.pushDiagnostic, + }); }; const normalizeModelPicker = () => { @@ -195,6 +255,7 @@ export function normalizeRegisteredProvider(params: { }); const docsPath = normalizeText(params.provider.docsPath); const aliases = normalizeTextList(params.provider.aliases); + const deprecatedProfileIds = normalizeTextList(params.provider.deprecatedProfileIds); const envVars = normalizeTextList(params.provider.envVars); const wizard = normalizeProviderWizard({ providerId: id, @@ -230,6 +291,7 @@ export function normalizeRegisteredProvider(params: { label: normalizeText(params.provider.label) ?? id, ...(docsPath ? { docsPath } : {}), ...(aliases ? { aliases } : {}), + ...(deprecatedProfileIds ? { deprecatedProfileIds } : {}), ...(envVars ? { envVars } : {}), auth, ...(catalog ? { catalog } : {}), diff --git a/src/plugins/provider-wizard.test.ts b/src/plugins/provider-wizard.test.ts index eff361ee1c9..d7b8348f9b2 100644 --- a/src/plugins/provider-wizard.test.ts +++ b/src/plugins/provider-wizard.test.ts @@ -61,6 +61,7 @@ describe("provider wizard boundaries", () => { ).toEqual({ provider, method: provider.auth[0], + wizard: provider.wizard?.setup, }); }); @@ -101,6 +102,41 @@ describe("provider wizard boundaries", () => { ).toEqual({ provider, method: provider.auth[0], + wizard: provider.auth[0]?.wizard, + }); + }); + + it("returns method wizard metadata for canonical choices", () => { + const provider = makeProvider({ + id: "anthropic", + label: "Anthropic", + auth: [ + { + id: "setup-token", + label: "setup-token", + kind: "token", + wizard: { + choiceId: "token", + modelAllowlist: { + allowedKeys: ["anthropic/claude-sonnet-4-6"], + initialSelections: ["anthropic/claude-sonnet-4-6"], + message: "Anthropic OAuth models", + }, + }, + run: vi.fn(), + }, + ], + }); + + expect( + resolveProviderPluginChoice({ + providers: [provider], + choice: "token", + }), + ).toEqual({ + provider, + method: provider.auth[0], + wizard: provider.auth[0]?.wizard, }); }); diff --git a/src/plugins/provider-wizard.ts b/src/plugins/provider-wizard.ts index cbe90178056..0b95a07f2b5 100644 --- a/src/plugins/provider-wizard.ts +++ b/src/plugins/provider-wizard.ts @@ -190,7 +190,11 @@ export function resolveProviderModelPickerEntries(params: { export function resolveProviderPluginChoice(params: { providers: ProviderPlugin[]; choice: string; -}): { provider: ProviderPlugin; method: ProviderAuthMethod } | null { +}): { + provider: ProviderPlugin; + method: ProviderAuthMethod; + wizard?: ProviderPluginWizardSetup; +} | null { const choice = params.choice.trim(); if (!choice) { return null; @@ -216,7 +220,7 @@ export function resolveProviderPluginChoice(params: { const choiceId = wizard.choiceId?.trim() || buildProviderPluginMethodChoice(provider.id, method.id); if (normalizeChoiceId(choiceId) === choice) { - return { provider, method }; + return { provider, method, wizard }; } } const setup = provider.wizard?.setup; @@ -225,7 +229,7 @@ export function resolveProviderPluginChoice(params: { if (normalizeChoiceId(setupChoiceId) === choice) { const method = resolveMethodById(provider, setup.methodId); if (method) { - return { provider, method }; + return { provider, method, wizard: setup }; } } } diff --git a/src/plugins/types.ts b/src/plugins/types.ts index f533b1b80a1..6dc5788b9eb 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -553,6 +553,18 @@ export type ProviderPluginWizardSetup = { groupLabel?: string; groupHint?: string; methodId?: string; + /** + * Optional model-allowlist prompt policy applied after this auth choice is + * selected in configure/onboarding flows. + * + * Keep this UI-facing and static. Provider logic that needs runtime state + * should stay in `run`/`runNonInteractive`. + */ + modelAllowlist?: { + allowedKeys?: string[]; + initialSelections?: string[]; + message?: string; + }; }; export type ProviderPluginWizardModelPicker = { @@ -773,6 +785,14 @@ export type ProviderPlugin = { * bearer token (for example Gemini CLI's `{ token, projectId }` payload). */ formatApiKey?: (cred: AuthProfileCredential) => string; + /** + * Legacy auth-profile ids that should be retired by `openclaw doctor`. + * + * Use this when a provider plugin replaces an older core-managed profile id + * and wants cleanup/migration messaging to live with the provider instead of + * in hardcoded doctor tables. + */ + deprecatedProfileIds?: string[]; /** * Provider-owned OAuth refresh. * From aa97368f7d6f5ed1e805ec739b13871b6008226f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 23:02:36 -0700 Subject: [PATCH 21/23] test: add openshell sandbox e2e smoke --- docs/help/testing.md | 19 +- package.json | 1 + test/openshell-sandbox.e2e.test.ts | 585 +++++++++++++++++++++++++++++ 3 files changed, 604 insertions(+), 1 deletion(-) create mode 100644 test/openshell-sandbox.e2e.test.ts diff --git a/docs/help/testing.md b/docs/help/testing.md index b2057e8a1da..9fa1404a8d4 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -61,7 +61,7 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost): - Command: `pnpm test:e2e` - Config: `vitest.e2e.config.ts` -- Files: `src/**/*.e2e.test.ts` +- Files: `src/**/*.e2e.test.ts`, `test/**/*.e2e.test.ts` - Runtime defaults: - Uses Vitest `vmForks` for faster file startup. - Uses adaptive workers (CI: 2-4, local: 4-8). @@ -77,6 +77,23 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost): - No real keys required - More moving parts than unit tests (can be slower) +### E2E: OpenShell backend smoke + +- Command: `pnpm test:e2e:openshell` +- File: `test/openshell-sandbox.e2e.test.ts` +- Scope: + - Starts an isolated OpenShell gateway on the host via Docker + - Creates a sandbox from a temporary local Dockerfile + - Exercises OpenClaw's OpenShell backend over real `sandbox ssh-config` + SSH exec + - Verifies remote-canonical filesystem behavior through the sandbox fs bridge +- Expectations: + - Opt-in only; not part of the default `pnpm test:e2e` run + - Requires a local `openshell` CLI plus a working Docker daemon + - Uses isolated `HOME` / `XDG_CONFIG_HOME`, then destroys the test gateway and sandbox +- Useful overrides: + - `OPENCLAW_E2E_OPENSHELL=1` to enable the test when running the broader e2e suite manually + - `OPENCLAW_E2E_OPENSHELL_COMMAND=/path/to/openshell` to point at a non-default CLI binary or wrapper script + ### Live (real providers + real models) - Command: `pnpm test:live` diff --git a/package.json b/package.json index 6aa553f5302..124975e63d1 100644 --- a/package.json +++ b/package.json @@ -319,6 +319,7 @@ "test:docker:plugins": "bash scripts/e2e/plugins-docker.sh", "test:docker:qr": "bash scripts/e2e/qr-import-docker.sh", "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:e2e:openshell": "OPENCLAW_E2E_OPENSHELL=1 vitest run --config vitest.e2e.config.ts test/openshell-sandbox.e2e.test.ts", "test:extensions": "vitest run --config vitest.extensions.config.ts", "test:fast": "vitest run --config vitest.unit.config.ts", "test:force": "node --import tsx scripts/test-force.ts", diff --git a/test/openshell-sandbox.e2e.test.ts b/test/openshell-sandbox.e2e.test.ts new file mode 100644 index 00000000000..21824db38ee --- /dev/null +++ b/test/openshell-sandbox.e2e.test.ts @@ -0,0 +1,585 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { createOpenShellSandboxBackendFactory } from "../extensions/openshell/src/backend.js"; +import { resolveOpenShellPluginConfig } from "../extensions/openshell/src/config.js"; +import { createSandboxTestContext } from "../src/agents/sandbox/test-fixtures.js"; + +const OPENCLAW_OPENSHELL_E2E = process.env.OPENCLAW_E2E_OPENSHELL === "1"; +const OPENCLAW_OPENSHELL_E2E_TIMEOUT_MS = 12 * 60_000; +const OPENCLAW_OPENSHELL_COMMAND = + process.env.OPENCLAW_E2E_OPENSHELL_COMMAND?.trim() || "openshell"; + +const CUSTOM_IMAGE_DOCKERFILE = `FROM python:3.13-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \\ + coreutils \\ + curl \\ + findutils \\ + iproute2 \\ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1000 sandbox && \\ + useradd -m -u 1000 -g sandbox sandbox + +RUN echo "openclaw-openshell-e2e" > /opt/openshell-e2e-marker.txt + +WORKDIR /sandbox +CMD ["sleep", "infinity"] +`; + +type ExecResult = { + code: number; + stdout: string; + stderr: string; +}; + +type HostPolicyServer = { + port: number; + close(): Promise; +}; + +async function runCommand(params: { + command: string; + args: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + stdin?: string | Buffer; + allowFailure?: boolean; + timeoutMs?: number; +}): Promise { + return await new Promise((resolve, reject) => { + const child = spawn(params.command, params.args, { + cwd: params.cwd, + env: params.env, + stdio: ["pipe", "pipe", "pipe"], + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let timedOut = false; + const timeout = + params.timeoutMs && params.timeoutMs > 0 + ? setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, params.timeoutMs) + : null; + + 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) => { + if (timeout) { + clearTimeout(timeout); + } + const stdout = Buffer.concat(stdoutChunks).toString("utf8"); + const stderr = Buffer.concat(stderrChunks).toString("utf8"); + if (timedOut) { + reject(new Error(`command timed out: ${params.command} ${params.args.join(" ")}`)); + return; + } + const exitCode = code ?? 0; + if (exitCode !== 0 && !params.allowFailure) { + reject( + new Error( + [ + `command failed: ${params.command} ${params.args.join(" ")}`, + `exit: ${exitCode}`, + stdout.trim() ? `stdout:\n${stdout}` : "", + stderr.trim() ? `stderr:\n${stderr}` : "", + ] + .filter(Boolean) + .join("\n"), + ), + ); + return; + } + resolve({ code: exitCode, stdout, stderr }); + }); + + child.stdin.end(params.stdin); + }); +} + +async function commandAvailable(command: string): Promise { + try { + const result = await runCommand({ + command, + args: ["--help"], + allowFailure: true, + timeoutMs: 20_000, + }); + return result.code === 0 || result.stdout.length > 0 || result.stderr.length > 0; + } catch { + return false; + } +} + +async function dockerReady(): Promise { + try { + const result = await runCommand({ + command: "docker", + args: ["version"], + allowFailure: true, + timeoutMs: 20_000, + }); + return result.code === 0; + } catch { + return false; + } +} + +async function allocatePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("failed to allocate local port"))); + return; + } + const { port } = address; + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(port); + }); + }); + }); +} + +function openshellEnv(rootDir: string): NodeJS.ProcessEnv { + const homeDir = path.join(rootDir, "home"); + const xdgDir = path.join(rootDir, "xdg"); + const cacheDir = path.join(rootDir, "xdg-cache"); + return { + ...process.env, + HOME: homeDir, + XDG_CONFIG_HOME: xdgDir, + XDG_CACHE_HOME: cacheDir, + }; +} + +function trimTrailingNewline(value: string): string { + return value.replace(/\r?\n$/, ""); +} + +async function startHostPolicyServer(): Promise { + const port = await allocatePort(); + const responseBody = JSON.stringify({ ok: true, message: "hello-from-host" }); + const serverScript = `from http.server import BaseHTTPRequestHandler, HTTPServer +import os + +BODY = os.environ["RESPONSE_BODY"].encode() + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(BODY))) + self.end_headers() + self.wfile.write(BODY) + + def do_POST(self): + length = int(self.headers.get("Content-Length", "0")) + if length: + self.rfile.read(length) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(BODY))) + self.end_headers() + self.wfile.write(BODY) + + def log_message(self, _format, *_args): + pass + +HTTPServer(("0.0.0.0", 8000), Handler).serve_forever() +`; + const startResult = await runCommand({ + command: "docker", + args: [ + "run", + "--detach", + "--rm", + "-e", + `RESPONSE_BODY=${responseBody}`, + "-p", + `${port}:8000`, + "python:3.13-alpine", + "python3", + "-c", + serverScript, + ], + timeoutMs: 60_000, + }); + const containerId = trimTrailingNewline(startResult.stdout.trim()); + if (!containerId) { + throw new Error("failed to start docker-backed host policy server"); + } + + const startedAt = Date.now(); + while (Date.now() - startedAt < 30_000) { + const readyResult = await runCommand({ + command: "docker", + args: [ + "exec", + containerId, + "python3", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000', timeout=1).read()", + ], + allowFailure: true, + timeoutMs: 15_000, + }); + if (readyResult.code === 0) { + return { + port, + async close() { + await runCommand({ + command: "docker", + args: ["rm", "-f", containerId], + allowFailure: true, + timeoutMs: 30_000, + }); + }, + }; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + await runCommand({ + command: "docker", + args: ["rm", "-f", containerId], + allowFailure: true, + timeoutMs: 30_000, + }); + throw new Error("docker-backed host policy server did not become ready"); +} + +function buildOpenShellPolicyYaml(params: { port: number; binaryPath: string }): string { + const networkPolicies = ` host_echo: + name: host-echo + endpoints: + - host: host.openshell.internal + port: ${params.port} + allowed_ips: + - "0.0.0.0/0" + binaries: + - path: ${params.binaryPath}`; + return `version: 1 + +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: +${networkPolicies} +`; +} + +async function runBackendExec(params: { + backend: Awaited>>; + command: string; + allowFailure?: boolean; + timeoutMs?: number; +}): Promise { + const execSpec = await params.backend.buildExecSpec({ + command: params.command, + env: {}, + usePty: false, + }); + let result: ExecResult | null = null; + try { + result = await runCommand({ + command: execSpec.argv[0] ?? "ssh", + args: execSpec.argv.slice(1), + env: execSpec.env, + allowFailure: params.allowFailure, + timeoutMs: params.timeoutMs, + }); + return result; + } finally { + await params.backend.finalizeExec?.({ + status: result?.code === 0 ? "completed" : "failed", + exitCode: result?.code ?? 1, + timedOut: false, + token: execSpec.finalizeToken, + }); + } +} + +describe("openshell sandbox backend e2e", () => { + it.runIf(process.platform !== "win32" && OPENCLAW_OPENSHELL_E2E)( + "creates a remote-canonical sandbox through OpenShell and executes over SSH", + { timeout: OPENCLAW_OPENSHELL_E2E_TIMEOUT_MS }, + async () => { + if (!(await dockerReady())) { + return; + } + if (!(await commandAvailable(OPENCLAW_OPENSHELL_COMMAND))) { + return; + } + + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-openshell-e2e-")); + const env = openshellEnv(rootDir); + const previousHome = process.env.HOME; + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME; + const previousXdgCacheHome = process.env.XDG_CACHE_HOME; + const workspaceDir = path.join(rootDir, "workspace"); + const dockerfileDir = path.join(rootDir, "custom-image"); + const dockerfilePath = path.join(dockerfileDir, "Dockerfile"); + const denyPolicyPath = path.join(rootDir, "deny-policy.yaml"); + const allowPolicyPath = path.join(rootDir, "allow-policy.yaml"); + const scopeSuffix = `${process.pid}-${Date.now()}`; + const gatewayName = `openclaw-e2e-${scopeSuffix}`; + const scopeKey = `session:openshell-e2e-deny:${scopeSuffix}`; + const allowSandboxName = `openclaw-policy-allow-${scopeSuffix}`; + const gatewayPort = await allocatePort(); + let hostPolicyServer: HostPolicyServer | null = null; + const sandboxCfg = { + mode: "all" as const, + backend: "openshell" as const, + scope: "session" as const, + workspaceAccess: "rw" as const, + workspaceRoot: path.join(rootDir, "sandboxes"), + docker: { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp"], + network: "none", + capDrop: ["ALL"], + env: {}, + }, + ssh: { + command: "ssh", + workspaceRoot: "/tmp/openclaw-sandboxes", + 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 pluginConfig = resolveOpenShellPluginConfig({ + command: OPENCLAW_OPENSHELL_COMMAND, + gateway: gatewayName, + from: dockerfilePath, + mode: "remote", + autoProviders: false, + policy: denyPolicyPath, + }); + const backendFactory = createOpenShellSandboxBackendFactory({ pluginConfig }); + const backend = await backendFactory({ + sessionKey: scopeKey, + scopeKey, + workspaceDir, + agentWorkspaceDir: workspaceDir, + cfg: sandboxCfg, + }); + + try { + process.env.HOME = env.HOME; + process.env.XDG_CONFIG_HOME = env.XDG_CONFIG_HOME; + process.env.XDG_CACHE_HOME = env.XDG_CACHE_HOME; + hostPolicyServer = await startHostPolicyServer(); + if (!hostPolicyServer) { + throw new Error("failed to start host policy server"); + } + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.mkdir(dockerfileDir, { recursive: true }); + await fs.writeFile(path.join(workspaceDir, "seed.txt"), "seed-from-local\n", "utf8"); + await fs.writeFile(dockerfilePath, CUSTOM_IMAGE_DOCKERFILE, "utf8"); + await fs.writeFile( + denyPolicyPath, + buildOpenShellPolicyYaml({ + port: hostPolicyServer.port, + binaryPath: "/usr/bin/false", + }), + "utf8", + ); + await fs.writeFile( + allowPolicyPath, + buildOpenShellPolicyYaml({ + port: hostPolicyServer.port, + binaryPath: "/**", + }), + "utf8", + ); + + await runCommand({ + command: OPENCLAW_OPENSHELL_COMMAND, + args: [ + "gateway", + "start", + "--name", + gatewayName, + "--port", + String(gatewayPort), + "--recreate", + ], + env, + timeoutMs: 8 * 60_000, + }); + + const execResult = await runBackendExec({ + backend, + command: "pwd && cat /opt/openshell-e2e-marker.txt && cat seed.txt", + timeoutMs: 2 * 60_000, + }); + + expect(execResult.code).toBe(0); + const stdout = execResult.stdout.trim(); + expect(stdout).toContain("/sandbox"); + expect(stdout).toContain("openclaw-openshell-e2e"); + expect(stdout).toContain("seed-from-local"); + + const curlPathResult = await runBackendExec({ + backend, + command: "command -v curl", + timeoutMs: 60_000, + }); + expect(trimTrailingNewline(curlPathResult.stdout.trim())).toMatch(/^\/.+\/curl$/); + + const sandbox = createSandboxTestContext({ + overrides: { + backendId: "openshell", + workspaceDir, + agentWorkspaceDir: workspaceDir, + runtimeId: backend.runtimeId, + runtimeLabel: backend.runtimeLabel, + containerName: backend.runtimeId, + containerWorkdir: backend.workdir, + backend, + }, + }); + const bridge = backend.createFsBridge?.({ sandbox }); + if (!bridge) { + throw new Error("openshell backend did not create a filesystem bridge"); + } + + await bridge.writeFile({ filePath: "nested/remote-only.txt", data: "hello-remote\n" }); + await expect( + fs.readFile(path.join(workspaceDir, "nested", "remote-only.txt"), "utf8"), + ).rejects.toThrow(); + await expect(bridge.readFile({ filePath: "nested/remote-only.txt" })).resolves.toEqual( + Buffer.from("hello-remote\n"), + ); + + const verifyResult = await runCommand({ + command: OPENCLAW_OPENSHELL_COMMAND, + args: ["sandbox", "ssh-config", backend.runtimeId], + env, + timeoutMs: 60_000, + }); + expect(verifyResult.code).toBe(0); + expect(trimTrailingNewline(verifyResult.stdout)).toContain("Host "); + + const blockedGetResult = await runBackendExec({ + backend, + command: `curl --fail --silent --show-error --max-time 15 "http://host.openshell.internal:${hostPolicyServer.port}/policy-test"`, + allowFailure: true, + timeoutMs: 60_000, + }); + expect(blockedGetResult.code).not.toBe(0); + expect(`${blockedGetResult.stdout}\n${blockedGetResult.stderr}`).toMatch(/403|deny/i); + + const allowedGetResult = await runCommand({ + command: OPENCLAW_OPENSHELL_COMMAND, + args: [ + "sandbox", + "create", + "--name", + allowSandboxName, + "--from", + dockerfilePath, + "--policy", + allowPolicyPath, + "--no-auto-providers", + "--no-keep", + "--", + "curl", + "--fail", + "--silent", + "--show-error", + "--max-time", + "15", + `http://host.openshell.internal:${hostPolicyServer.port}/policy-test`, + ], + env, + timeoutMs: 60_000, + }); + expect(allowedGetResult.code).toBe(0); + expect(allowedGetResult.stdout).toContain('"message":"hello-from-host"'); + } finally { + await runCommand({ + command: OPENCLAW_OPENSHELL_COMMAND, + args: ["sandbox", "delete", backend.runtimeId], + env, + allowFailure: true, + timeoutMs: 2 * 60_000, + }); + await runCommand({ + command: OPENCLAW_OPENSHELL_COMMAND, + args: ["sandbox", "delete", allowSandboxName], + env, + allowFailure: true, + timeoutMs: 2 * 60_000, + }); + await runCommand({ + command: OPENCLAW_OPENSHELL_COMMAND, + args: ["gateway", "destroy", "--name", gatewayName], + env, + allowFailure: true, + timeoutMs: 3 * 60_000, + }); + await hostPolicyServer?.close().catch(() => {}); + await fs.rm(rootDir, { recursive: true, force: true }); + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome; + } + if (previousXdgCacheHome === undefined) { + delete process.env.XDG_CACHE_HOME; + } else { + process.env.XDG_CACHE_HOME = previousXdgCacheHome; + } + } + }, + ); +}); From fa62231afca31a614d2494a4148e08b130a1601a Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Mon, 16 Mar 2026 01:07:09 -0500 Subject: [PATCH 22/23] feishu: add structured card actions and interactive approval flows (#47873) * feishu: add structured card actions and interactive approval flows * feishu: address review fixes and test-gate regressions * feishu: hold inflight card dedup until completion * feishu: restore fire-and-forget bot menu handling * feishu: format card interaction helpers * Feishu: add changelog entry for card interactions * Feishu: add changelog entry for ACP session binding --- CHANGELOG.md | 2 + extensions/feishu/src/bot.card-action.test.ts | 344 +++++++++++++++++- extensions/feishu/src/card-action.ts | 313 +++++++++++++--- .../feishu/src/card-interaction.test.ts | 129 +++++++ extensions/feishu/src/card-interaction.ts | 168 +++++++++ extensions/feishu/src/card-ux-approval.ts | 65 ++++ .../feishu/src/card-ux-launcher.test.ts | 98 +++++ extensions/feishu/src/card-ux-launcher.ts | 120 ++++++ extensions/feishu/src/card-ux-shared.ts | 33 ++ extensions/feishu/src/monitor.account.ts | 27 +- .../feishu/src/monitor.bot-menu.test.ts | 229 ++++++++++++ src/security/audit.ts | 9 +- 12 files changed, 1485 insertions(+), 52 deletions(-) create mode 100644 extensions/feishu/src/card-interaction.test.ts create mode 100644 extensions/feishu/src/card-interaction.ts create mode 100644 extensions/feishu/src/card-ux-approval.ts create mode 100644 extensions/feishu/src/card-ux-launcher.test.ts create mode 100644 extensions/feishu/src/card-ux-launcher.ts create mode 100644 extensions/feishu/src/card-ux-shared.ts create mode 100644 extensions/feishu/src/monitor.bot-menu.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aaf84db974..ddfb252fc71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ Docs: https://docs.openclaw.ai - 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. +- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873) +- Feishu/ACP: add current-conversation ACP and subagent session binding for supported DMs and topic conversations, including completion delivery back to the originating Feishu conversation. (#46819) ### Fixes diff --git a/extensions/feishu/src/bot.card-action.test.ts b/extensions/feishu/src/bot.card-action.test.ts index 90967b593bd..2df1ce361a1 100644 --- a/extensions/feishu/src/bot.card-action.test.ts +++ b/extensions/feishu/src/bot.card-action.test.ts @@ -1,5 +1,15 @@ -import { describe, it, expect, vi } from "vitest"; -import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + handleFeishuCardAction, + resetProcessedFeishuCardActionTokensForTests, + type FeishuCardActionEvent, +} from "./card-action.js"; +import { createFeishuCardInteractionEnvelope } from "./card-interaction.js"; +import { + FEISHU_APPROVAL_CANCEL_ACTION, + FEISHU_APPROVAL_CONFIRM_ACTION, + FEISHU_APPROVAL_REQUEST_ACTION, +} from "./card-ux-approval.js"; // Mock resolveFeishuAccount vi.mock("./accounts.js", () => ({ @@ -11,12 +21,25 @@ vi.mock("./bot.js", () => ({ handleFeishuMessage: vi.fn(), })); +const sendCardFeishuMock = vi.hoisted(() => vi.fn()); +const sendMessageFeishuMock = vi.hoisted(() => vi.fn()); + +vi.mock("./send.js", () => ({ + sendCardFeishu: sendCardFeishuMock, + sendMessageFeishu: sendMessageFeishuMock, +})); + import { handleFeishuMessage } from "./bot.js"; describe("Feishu Card Action Handler", () => { const cfg = {} as any; // Minimal mock const runtime = { log: vi.fn(), error: vi.fn() } as any; + beforeEach(() => { + vi.clearAllMocks(); + resetProcessedFeishuCardActionTokensForTests(); + }); + it("handles card action with text payload", async () => { const event: FeishuCardActionEvent = { operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, @@ -60,4 +83,321 @@ describe("Feishu Card Action Handler", () => { }), ); }); + + it("routes quick command actions with operator and conversation context", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok3", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + sender: expect.objectContaining({ + sender_id: expect.objectContaining({ + open_id: "u123", + user_id: "uid1", + union_id: "un1", + }), + }), + message: expect.objectContaining({ + chat_id: "chat1", + content: '{"text":"/help"}', + }), + }), + }), + ); + }); + + it("opens an approval card for metadata actions", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok4", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "meta", + a: FEISHU_APPROVAL_REQUEST_ACTION, + m: { + command: "/new", + prompt: "Start a fresh session?", + }, + c: { + u: "u123", + h: "chat1", + t: "group", + s: "agent:codex:feishu:chat:chat1", + e: Date.now() + 60_000, + }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" }); + + expect(sendCardFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat:chat1", + accountId: "main", + card: expect.objectContaining({ + header: expect.objectContaining({ + title: expect.objectContaining({ content: "Confirm action" }), + }), + body: expect.objectContaining({ + elements: expect.arrayContaining([ + expect.objectContaining({ + tag: "action", + actions: expect.arrayContaining([ + expect.objectContaining({ + value: expect.objectContaining({ + c: expect.objectContaining({ + u: "u123", + h: "chat1", + t: "group", + s: "agent:codex:feishu:chat:chat1", + }), + }), + }), + ]), + }), + ]), + }), + }), + }), + ); + expect(handleFeishuMessage).not.toHaveBeenCalled(); + }); + + it("runs approval confirmation through the normal message path", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok5", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: FEISHU_APPROVAL_CONFIRM_ACTION, + q: "/new", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + message: expect.objectContaining({ + content: '{"text":"/new"}', + }), + }), + }), + ); + }); + + it("safely rejects stale structured actions", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok6", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() - 1 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat:chat1", + text: expect.stringContaining("expired"), + }), + ); + expect(handleFeishuMessage).not.toHaveBeenCalled(); + }); + + it("safely rejects wrong-user structured actions", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u999", user_id: "uid1", union_id: "un1" }, + token: "tok7", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u999", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining("different user"), + }), + ); + expect(handleFeishuMessage).not.toHaveBeenCalled(); + }); + + it("sends a lightweight cancellation notice", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok8", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "button", + a: FEISHU_APPROVAL_CANCEL_ACTION, + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat:chat1", + text: "Cancelled.", + }), + ); + }); + + it("preserves p2p callbacks for DM quick actions", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok9", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "p2p-chat-1", t: "p2p", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "p2p-chat-1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + message: expect.objectContaining({ + chat_id: "p2p-chat-1", + chat_type: "p2p", + }), + }), + }), + ); + }); + + it("drops duplicate structured callback tokens", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok10", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledTimes(1); + }); + + it("releases a claimed token when dispatch fails so retries can succeed", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok11", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + vi.mocked(handleFeishuMessage) + .mockRejectedValueOnce(new Error("transient")) + .mockResolvedValueOnce(undefined as never); + + await expect(handleFeishuCardAction({ cfg, event, runtime })).rejects.toThrow("transient"); + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledTimes(2); + }); + + it("keeps an in-flight token claimed while a slow dispatch is still running", async () => { + vi.useFakeTimers(); + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok12", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + let resolveDispatch: (() => void) | undefined; + vi.mocked(handleFeishuMessage).mockImplementation( + () => + new Promise((resolve) => { + resolveDispatch = resolve; + }) as never, + ); + + const first = handleFeishuCardAction({ cfg, event, runtime }); + await vi.advanceTimersByTimeAsync(61_000); + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledTimes(1); + + resolveDispatch?.(); + await first; + vi.useRealTimers(); + }); }); diff --git a/extensions/feishu/src/card-action.ts b/extensions/feishu/src/card-action.ts index e4f76846316..d664b8d6af2 100644 --- a/extensions/feishu/src/card-action.ts +++ b/extensions/feishu/src/card-action.ts @@ -1,6 +1,14 @@ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu"; import { resolveFeishuAccount } from "./accounts.js"; import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js"; +import { decodeFeishuCardAction, buildFeishuCardActionTextFallback } from "./card-interaction.js"; +import { + createApprovalCard, + FEISHU_APPROVAL_CANCEL_ACTION, + FEISHU_APPROVAL_CONFIRM_ACTION, + FEISHU_APPROVAL_REQUEST_ACTION, +} from "./card-ux-approval.js"; +import { sendCardFeishu, sendMessageFeishu } from "./send.js"; export type FeishuCardActionEvent = { operator: { @@ -20,18 +28,142 @@ export type FeishuCardActionEvent = { }; }; -function buildCardActionTextFallback(event: FeishuCardActionEvent): string { - const actionValue = event.action.value; - if (typeof actionValue === "object" && actionValue !== null) { - if ("text" in actionValue && typeof actionValue.text === "string") { - return actionValue.text; +const FEISHU_APPROVAL_CARD_TTL_MS = 5 * 60_000; +const FEISHU_CARD_ACTION_TOKEN_TTL_MS = 15 * 60_000; +const processedCardActionTokens = new Map< + string, + { status: "inflight" | "completed"; expiresAt: number } +>(); + +export function resetProcessedFeishuCardActionTokensForTests(): void { + processedCardActionTokens.clear(); +} + +function pruneProcessedCardActionTokens(now: number): void { + for (const [key, entry] of processedCardActionTokens.entries()) { + if (entry.expiresAt <= now) { + processedCardActionTokens.delete(key); } - if ("command" in actionValue && typeof actionValue.command === "string") { - return actionValue.command; - } - return JSON.stringify(actionValue); } - return String(actionValue); +} + +function beginFeishuCardActionToken(params: { + token: string; + accountId: string; + now?: number; +}): boolean { + const now = params.now ?? Date.now(); + pruneProcessedCardActionTokens(now); + const normalizedToken = params.token.trim(); + if (!normalizedToken) { + return true; + } + const key = `${params.accountId}:${normalizedToken}`; + const existing = processedCardActionTokens.get(key); + if (existing && existing.expiresAt > now) { + return false; + } + processedCardActionTokens.set(key, { + status: "inflight", + expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS, + }); + return true; +} + +function completeFeishuCardActionToken(params: { + token: string; + accountId: string; + now?: number; +}): void { + const now = params.now ?? Date.now(); + const normalizedToken = params.token.trim(); + if (!normalizedToken) { + return; + } + processedCardActionTokens.set(`${params.accountId}:${normalizedToken}`, { + status: "completed", + expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS, + }); +} + +function releaseFeishuCardActionToken(params: { token: string; accountId: string }): void { + const normalizedToken = params.token.trim(); + if (!normalizedToken) { + return; + } + processedCardActionTokens.delete(`${params.accountId}:${normalizedToken}`); +} + +function buildSyntheticMessageEvent( + event: FeishuCardActionEvent, + content: string, + chatType?: "p2p" | "group", +): FeishuMessageEvent { + return { + sender: { + sender_id: { + open_id: event.operator.open_id, + user_id: event.operator.user_id, + union_id: event.operator.union_id, + }, + }, + message: { + message_id: `card-action-${event.token}`, + chat_id: event.context.chat_id || event.operator.open_id, + chat_type: chatType ?? (event.context.chat_id ? "group" : "p2p"), + message_type: "text", + content: JSON.stringify({ text: content }), + }, + }; +} + +function resolveCallbackTarget(event: FeishuCardActionEvent): string { + const chatId = event.context.chat_id?.trim(); + if (chatId) { + return `chat:${chatId}`; + } + return `user:${event.operator.open_id}`; +} + +async function dispatchSyntheticCommand(params: { + cfg: ClawdbotConfig; + event: FeishuCardActionEvent; + command: string; + botOpenId?: string; + runtime?: RuntimeEnv; + accountId?: string; + chatType?: "p2p" | "group"; +}): Promise { + await handleFeishuMessage({ + cfg: params.cfg, + event: buildSyntheticMessageEvent(params.event, params.command, params.chatType), + botOpenId: params.botOpenId, + runtime: params.runtime, + accountId: params.accountId, + }); +} + +async function sendInvalidInteractionNotice(params: { + cfg: ClawdbotConfig; + event: FeishuCardActionEvent; + reason: "malformed" | "stale" | "wrong_user" | "wrong_conversation"; + accountId?: string; +}): Promise { + const reasonText = + params.reason === "stale" + ? "This card action has expired. Open a fresh launcher card and try again." + : params.reason === "wrong_user" + ? "This card action belongs to a different user." + : params.reason === "wrong_conversation" + ? "This card action belongs to a different conversation." + : "This card action payload is invalid."; + + await sendMessageFeishu({ + cfg: params.cfg, + to: resolveCallbackTarget(params.event), + text: `⚠️ ${reasonText}`, + accountId: params.accountId, + }); } export async function handleFeishuCardAction(params: { @@ -44,36 +176,135 @@ export async function handleFeishuCardAction(params: { const { cfg, event, runtime, accountId } = params; const account = resolveFeishuAccount({ cfg, accountId }); const log = runtime?.log ?? console.log; - const content = buildCardActionTextFallback(event); - - // Construct a synthetic message event - const messageEvent: FeishuMessageEvent = { - sender: { - sender_id: { - open_id: event.operator.open_id, - user_id: event.operator.user_id, - union_id: event.operator.union_id, - }, - }, - message: { - message_id: `card-action-${event.token}`, - chat_id: event.context.chat_id || event.operator.open_id, - chat_type: event.context.chat_id ? "group" : "p2p", - message_type: "text", - content: JSON.stringify({ text: content }), - }, - }; - - log( - `feishu[${account.accountId}]: handling card action from ${event.operator.open_id}: ${content}`, - ); - - // Dispatch as normal message - await handleFeishuMessage({ - cfg, - event: messageEvent, - botOpenId: params.botOpenId, - runtime, - accountId, + const decoded = decodeFeishuCardAction({ event }); + const claimedToken = beginFeishuCardActionToken({ + token: event.token, + accountId: account.accountId, }); + if (!claimedToken) { + log(`feishu[${account.accountId}]: skipping duplicate card action token ${event.token}`); + return; + } + + try { + if (decoded.kind === "invalid") { + log( + `feishu[${account.accountId}]: rejected card action from ${event.operator.open_id}: ${decoded.reason}`, + ); + await sendInvalidInteractionNotice({ + cfg, + event, + reason: decoded.reason, + accountId, + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + return; + } + + if (decoded.kind === "structured") { + const { envelope } = decoded; + log( + `feishu[${account.accountId}]: handling structured card action ${envelope.a} from ${event.operator.open_id}`, + ); + + if (envelope.a === FEISHU_APPROVAL_REQUEST_ACTION) { + const command = typeof envelope.m?.command === "string" ? envelope.m.command.trim() : ""; + if (!command) { + await sendInvalidInteractionNotice({ + cfg, + event, + reason: "malformed", + accountId, + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + return; + } + const prompt = + typeof envelope.m?.prompt === "string" && envelope.m.prompt.trim() + ? envelope.m.prompt + : `Run \`${command}\` in this Feishu conversation?`; + await sendCardFeishu({ + cfg, + to: resolveCallbackTarget(event), + card: createApprovalCard({ + operatorOpenId: event.operator.open_id, + chatId: event.context.chat_id || undefined, + command, + prompt, + sessionKey: envelope.c?.s, + expiresAt: Date.now() + FEISHU_APPROVAL_CARD_TTL_MS, + chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"), + confirmLabel: command === "/reset" ? "Reset" : "Confirm", + }), + accountId, + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + return; + } + + if (envelope.a === FEISHU_APPROVAL_CANCEL_ACTION) { + await sendMessageFeishu({ + cfg, + to: resolveCallbackTarget(event), + text: "Cancelled.", + accountId, + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + return; + } + + if (envelope.a === FEISHU_APPROVAL_CONFIRM_ACTION || envelope.k === "quick") { + const command = envelope.q?.trim(); + if (!command) { + await sendInvalidInteractionNotice({ + cfg, + event, + reason: "malformed", + accountId, + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + return; + } + await dispatchSyntheticCommand({ + cfg, + event, + command, + botOpenId: params.botOpenId, + runtime, + accountId, + chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"), + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + return; + } + + await sendInvalidInteractionNotice({ + cfg, + event, + reason: "malformed", + accountId, + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + return; + } + + const content = buildFeishuCardActionTextFallback(event); + + log( + `feishu[${account.accountId}]: handling card action from ${event.operator.open_id}: ${content}`, + ); + + await dispatchSyntheticCommand({ + cfg, + event, + command: content, + botOpenId: params.botOpenId, + runtime, + accountId, + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + } catch (err) { + releaseFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + throw err; + } } diff --git a/extensions/feishu/src/card-interaction.test.ts b/extensions/feishu/src/card-interaction.test.ts new file mode 100644 index 00000000000..58aee261162 --- /dev/null +++ b/extensions/feishu/src/card-interaction.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { + buildFeishuCardActionTextFallback, + createFeishuCardInteractionEnvelope, + decodeFeishuCardAction, +} from "./card-interaction.js"; + +describe("feishu card interaction decoder", () => { + it("decodes valid structured payloads", () => { + const result = decodeFeishuCardAction({ + now: 1_700_000_000_000, + event: { + operator: { open_id: "u123" }, + context: { chat_id: "chat1" }, + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: 1_700_000_060_000 }, + }), + }, + }, + }); + + expect(result).toEqual( + expect.objectContaining({ + kind: "structured", + envelope: expect.objectContaining({ + q: "/help", + }), + }), + ); + }); + + it("falls back for legacy text-like payloads", () => { + const result = decodeFeishuCardAction({ + event: { + operator: { open_id: "u123" }, + context: { chat_id: "chat1" }, + action: { value: { text: "/ping" } }, + }, + }); + + expect(result).toEqual({ kind: "legacy", text: "/ping" }); + expect( + buildFeishuCardActionTextFallback({ + operator: { open_id: "u123" }, + context: { chat_id: "chat1" }, + action: { value: { command: "/new" } }, + }), + ).toBe("/new"); + }); + + it("rejects malformed structured payloads", () => { + const result = decodeFeishuCardAction({ + event: { + operator: { open_id: "u123" }, + context: { chat_id: "chat1" }, + action: { + value: { + oc: "ocf1", + k: "quick", + a: "broken", + m: { bad: { nested: true } }, + }, + }, + }, + }); + + expect(result).toEqual({ kind: "invalid", reason: "malformed" }); + }); + + it("rejects stale payloads", () => { + const result = decodeFeishuCardAction({ + now: 100, + event: { + operator: { open_id: "u123" }, + context: { chat_id: "chat1" }, + action: { + value: createFeishuCardInteractionEnvelope({ + k: "button", + a: "stale", + c: { e: 99, t: "group" }, + }), + }, + }, + }); + + expect(result).toEqual({ kind: "invalid", reason: "stale" }); + }); + + it("rejects wrong-conversation payloads when chat context is enforced", () => { + const result = decodeFeishuCardAction({ + event: { + operator: { open_id: "u123" }, + context: { chat_id: "chat2" }, + action: { + value: createFeishuCardInteractionEnvelope({ + k: "button", + a: "scoped", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + }, + }, + }); + + expect(result).toEqual({ kind: "invalid", reason: "wrong_conversation" }); + }); + + it("rejects malformed chat-type context", () => { + const result = decodeFeishuCardAction({ + event: { + operator: { open_id: "u123" }, + context: { chat_id: "chat1" }, + action: { + value: { + oc: "ocf1", + k: "button", + a: "bad", + c: { t: "private" }, + }, + }, + }, + }); + + expect(result).toEqual({ kind: "invalid", reason: "malformed" }); + }); +}); diff --git a/extensions/feishu/src/card-interaction.ts b/extensions/feishu/src/card-interaction.ts new file mode 100644 index 00000000000..1da2df05baf --- /dev/null +++ b/extensions/feishu/src/card-interaction.ts @@ -0,0 +1,168 @@ +export const FEISHU_CARD_INTERACTION_VERSION = "ocf1"; + +export type FeishuCardInteractionKind = "button" | "quick" | "meta"; +export type FeishuCardInteractionReason = + | "malformed" + | "stale" + | "wrong_user" + | "wrong_conversation"; + +export type FeishuCardInteractionMetadata = Record< + string, + string | number | boolean | null | undefined +>; + +export type FeishuCardInteractionEnvelope = { + oc: typeof FEISHU_CARD_INTERACTION_VERSION; + k: FeishuCardInteractionKind; + a: string; + q?: string; + m?: FeishuCardInteractionMetadata; + c?: { + u?: string; + h?: string; + s?: string; + e?: number; + t?: "p2p" | "group"; + }; +}; + +export type FeishuCardActionEventLike = { + operator: { + open_id?: string; + }; + action: { + value: unknown; + }; + context: { + chat_id?: string; + }; +}; + +export type DecodedFeishuCardAction = + | { + kind: "structured"; + envelope: FeishuCardInteractionEnvelope; + } + | { + kind: "legacy"; + text: string; + } + | { + kind: "invalid"; + reason: FeishuCardInteractionReason; + }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isInteractionKind(value: unknown): value is FeishuCardInteractionKind { + return value === "button" || value === "quick" || value === "meta"; +} + +function isMetadataValue(value: unknown): value is string | number | boolean | null | undefined { + return ( + value === null || + value === undefined || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ); +} + +export function createFeishuCardInteractionEnvelope( + envelope: Omit, +): FeishuCardInteractionEnvelope { + return { + oc: FEISHU_CARD_INTERACTION_VERSION, + ...envelope, + }; +} + +export function buildFeishuCardActionTextFallback(event: FeishuCardActionEventLike): string { + const actionValue = event.action.value; + if (isRecord(actionValue)) { + if (typeof actionValue.text === "string") { + return actionValue.text; + } + if (typeof actionValue.command === "string") { + return actionValue.command; + } + return JSON.stringify(actionValue); + } + return String(actionValue); +} + +export function decodeFeishuCardAction(params: { + event: FeishuCardActionEventLike; + now?: number; +}): DecodedFeishuCardAction { + const { event, now = Date.now() } = params; + const actionValue = event.action.value; + if (!isRecord(actionValue) || actionValue.oc !== FEISHU_CARD_INTERACTION_VERSION) { + return { + kind: "legacy", + text: buildFeishuCardActionTextFallback(event), + }; + } + + if (!isInteractionKind(actionValue.k) || typeof actionValue.a !== "string" || !actionValue.a) { + return { kind: "invalid", reason: "malformed" }; + } + + if (actionValue.q !== undefined && typeof actionValue.q !== "string") { + return { kind: "invalid", reason: "malformed" }; + } + + if (actionValue.m !== undefined) { + if (!isRecord(actionValue.m)) { + return { kind: "invalid", reason: "malformed" }; + } + for (const value of Object.values(actionValue.m)) { + if (!isMetadataValue(value)) { + return { kind: "invalid", reason: "malformed" }; + } + } + } + + if (actionValue.c !== undefined) { + if (!isRecord(actionValue.c)) { + return { kind: "invalid", reason: "malformed" }; + } + if (actionValue.c.u !== undefined && typeof actionValue.c.u !== "string") { + return { kind: "invalid", reason: "malformed" }; + } + if (actionValue.c.h !== undefined && typeof actionValue.c.h !== "string") { + return { kind: "invalid", reason: "malformed" }; + } + if (actionValue.c.s !== undefined && typeof actionValue.c.s !== "string") { + return { kind: "invalid", reason: "malformed" }; + } + if (actionValue.c.e !== undefined && !Number.isFinite(actionValue.c.e)) { + return { kind: "invalid", reason: "malformed" }; + } + if (actionValue.c.t !== undefined && actionValue.c.t !== "p2p" && actionValue.c.t !== "group") { + return { kind: "invalid", reason: "malformed" }; + } + + if (typeof actionValue.c.e === "number" && actionValue.c.e < now) { + return { kind: "invalid", reason: "stale" }; + } + + const expectedUser = actionValue.c.u?.trim(); + if (expectedUser && expectedUser !== (event.operator.open_id ?? "").trim()) { + return { kind: "invalid", reason: "wrong_user" }; + } + + const expectedChat = actionValue.c.h?.trim(); + if (expectedChat && expectedChat !== (event.context.chat_id ?? "").trim()) { + return { kind: "invalid", reason: "wrong_conversation" }; + } + } + + return { + kind: "structured", + envelope: actionValue as FeishuCardInteractionEnvelope, + }; +} diff --git a/extensions/feishu/src/card-ux-approval.ts b/extensions/feishu/src/card-ux-approval.ts new file mode 100644 index 00000000000..944ace931ea --- /dev/null +++ b/extensions/feishu/src/card-ux-approval.ts @@ -0,0 +1,65 @@ +import { createFeishuCardInteractionEnvelope } from "./card-interaction.js"; +import { buildFeishuCardButton, buildFeishuCardInteractionContext } from "./card-ux-shared.js"; + +export const FEISHU_APPROVAL_REQUEST_ACTION = "feishu.quick_actions.request_approval"; +export const FEISHU_APPROVAL_CONFIRM_ACTION = "feishu.approval.confirm"; +export const FEISHU_APPROVAL_CANCEL_ACTION = "feishu.approval.cancel"; + +export function createApprovalCard(params: { + operatorOpenId: string; + chatId?: string; + command: string; + prompt: string; + expiresAt: number; + chatType?: "p2p" | "group"; + sessionKey?: string; + confirmLabel?: string; + cancelLabel?: string; +}): Record { + const context = buildFeishuCardInteractionContext(params); + + return { + schema: "2.0", + config: { + wide_screen_mode: true, + }, + header: { + title: { + tag: "plain_text", + content: "Confirm action", + }, + template: "orange", + }, + body: { + elements: [ + { + tag: "markdown", + content: params.prompt, + }, + { + tag: "action", + actions: [ + buildFeishuCardButton({ + label: params.confirmLabel ?? "Confirm", + type: "primary", + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: FEISHU_APPROVAL_CONFIRM_ACTION, + q: params.command, + c: context, + }), + }), + buildFeishuCardButton({ + label: params.cancelLabel ?? "Cancel", + value: createFeishuCardInteractionEnvelope({ + k: "button", + a: FEISHU_APPROVAL_CANCEL_ACTION, + c: context, + }), + }), + ], + }, + ], + }, + }; +} diff --git a/extensions/feishu/src/card-ux-launcher.test.ts b/extensions/feishu/src/card-ux-launcher.test.ts new file mode 100644 index 00000000000..6f9f7917daf --- /dev/null +++ b/extensions/feishu/src/card-ux-launcher.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { + createQuickActionLauncherCard, + isFeishuQuickActionMenuEventKey, + maybeHandleFeishuQuickActionMenu, +} from "./card-ux-launcher.js"; + +const sendCardFeishuMock = vi.hoisted(() => vi.fn()); + +vi.mock("./send.js", () => ({ + sendCardFeishu: sendCardFeishuMock, +})); + +describe("feishu quick-action launcher", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("recognizes the quick-actions bot menu key", () => { + expect(isFeishuQuickActionMenuEventKey("quick-actions")).toBe(true); + expect(isFeishuQuickActionMenuEventKey("other")).toBe(false); + }); + + it("builds a launcher card with interactive actions", () => { + const card = createQuickActionLauncherCard({ + operatorOpenId: "u123", + chatId: "chat1", + expiresAt: 123, + sessionKey: "agent:codex:feishu:chat:chat1", + }) as { + body: { + elements: Array<{ + tag: string; + actions?: Array<{ value?: { oc?: string; c?: { s?: string; t?: string } } }>; + }>; + }; + }; + + const actionBlock = card.body.elements.find((entry) => entry.tag === "action"); + expect(actionBlock?.actions).toHaveLength(3); + expect(actionBlock?.actions?.[0]?.value?.oc).toBe("ocf1"); + expect(actionBlock?.actions?.[0]?.value?.c?.s).toBe("agent:codex:feishu:chat:chat1"); + expect(actionBlock?.actions?.[0]?.value?.c?.t).toBeUndefined(); + }); + + it("opens the launcher from a supported bot menu event", async () => { + sendCardFeishuMock.mockResolvedValue({ messageId: "m1", chatId: "c1" }); + + const handled = await maybeHandleFeishuQuickActionMenu({ + cfg: {} as any, + eventKey: "quick-actions", + operatorOpenId: "u123", + accountId: "main", + now: 100, + }); + + expect(handled).toBe(true); + expect(sendCardFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "user:u123", + accountId: "main", + card: expect.objectContaining({ + body: expect.objectContaining({ + elements: expect.arrayContaining([ + expect.objectContaining({ + tag: "action", + actions: expect.arrayContaining([ + expect.objectContaining({ + value: expect.objectContaining({ + c: expect.objectContaining({ + t: "p2p", + }), + }), + }), + ]), + }), + ]), + }), + }), + }), + ); + }); + + it("falls back to legacy menu handling when launcher send fails", async () => { + sendCardFeishuMock.mockRejectedValueOnce(new Error("network")); + + const handled = await maybeHandleFeishuQuickActionMenu({ + cfg: {} as any, + eventKey: "quick-actions", + operatorOpenId: "u123", + accountId: "main", + runtime: { log: vi.fn() } as any, + now: 100, + }); + + expect(handled).toBe(false); + }); +}); diff --git a/extensions/feishu/src/card-ux-launcher.ts b/extensions/feishu/src/card-ux-launcher.ts new file mode 100644 index 00000000000..3303bc2ed77 --- /dev/null +++ b/extensions/feishu/src/card-ux-launcher.ts @@ -0,0 +1,120 @@ +import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu"; +import { createFeishuCardInteractionEnvelope } from "./card-interaction.js"; +import { FEISHU_APPROVAL_REQUEST_ACTION } from "./card-ux-approval.js"; +import { buildFeishuCardButton, buildFeishuCardInteractionContext } from "./card-ux-shared.js"; +import { sendCardFeishu } from "./send.js"; + +export const FEISHU_QUICK_ACTION_CARD_TTL_MS = 10 * 60_000; + +const QUICK_ACTION_MENU_KEYS = new Set(["quick-actions", "quick_actions", "launcher"]); + +export function isFeishuQuickActionMenuEventKey(eventKey: string): boolean { + return QUICK_ACTION_MENU_KEYS.has(eventKey.trim().toLowerCase()); +} + +export function createQuickActionLauncherCard(params: { + operatorOpenId: string; + chatId?: string; + expiresAt: number; + chatType?: "p2p" | "group"; + sessionKey?: string; +}): Record { + const context = buildFeishuCardInteractionContext(params); + return { + schema: "2.0", + config: { + wide_screen_mode: true, + }, + header: { + title: { + tag: "plain_text", + content: "Quick actions", + }, + template: "indigo", + }, + body: { + elements: [ + { + tag: "markdown", + content: "Run common actions without typing raw commands.", + }, + { + tag: "action", + actions: [ + buildFeishuCardButton({ + label: "Help", + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: context, + }), + }), + buildFeishuCardButton({ + label: "New session", + type: "primary", + value: createFeishuCardInteractionEnvelope({ + k: "meta", + a: FEISHU_APPROVAL_REQUEST_ACTION, + m: { + command: "/new", + prompt: "Start a fresh session? This will reset the current chat context.", + }, + c: context, + }), + }), + buildFeishuCardButton({ + label: "Reset", + type: "danger", + value: createFeishuCardInteractionEnvelope({ + k: "meta", + a: FEISHU_APPROVAL_REQUEST_ACTION, + m: { + command: "/reset", + prompt: "Reset this session now? Any active conversation state will be cleared.", + }, + c: context, + }), + }), + ], + }, + ], + }, + }; +} + +export async function maybeHandleFeishuQuickActionMenu(params: { + cfg: ClawdbotConfig; + eventKey: string; + operatorOpenId: string; + runtime?: RuntimeEnv; + accountId?: string; + now?: number; +}): Promise { + if (!isFeishuQuickActionMenuEventKey(params.eventKey)) { + return false; + } + + const expiresAt = (params.now ?? Date.now()) + FEISHU_QUICK_ACTION_CARD_TTL_MS; + try { + await sendCardFeishu({ + cfg: params.cfg, + to: `user:${params.operatorOpenId}`, + card: createQuickActionLauncherCard({ + operatorOpenId: params.operatorOpenId, + expiresAt, + chatType: "p2p", + }), + accountId: params.accountId, + }); + } catch (err) { + params.runtime?.log?.( + `feishu[${params.accountId ?? "default"}]: failed to open quick-action launcher for ${params.operatorOpenId}: ${String(err)}`, + ); + return false; + } + params.runtime?.log?.( + `feishu[${params.accountId ?? "default"}]: opened quick-action launcher for ${params.operatorOpenId}`, + ); + return true; +} diff --git a/extensions/feishu/src/card-ux-shared.ts b/extensions/feishu/src/card-ux-shared.ts new file mode 100644 index 00000000000..02133c39a5c --- /dev/null +++ b/extensions/feishu/src/card-ux-shared.ts @@ -0,0 +1,33 @@ +import type { FeishuCardInteractionEnvelope } from "./card-interaction.js"; + +export function buildFeishuCardButton(params: { + label: string; + value: FeishuCardInteractionEnvelope; + type?: "default" | "primary" | "danger"; +}) { + return { + tag: "button", + text: { + tag: "plain_text", + content: params.label, + }, + type: params.type ?? "default", + value: params.value, + }; +} + +export function buildFeishuCardInteractionContext(params: { + operatorOpenId: string; + chatId?: string; + expiresAt: number; + chatType?: "p2p" | "group"; + sessionKey?: string; +}) { + return { + u: params.operatorOpenId, + ...(params.chatId ? { h: params.chatId } : {}), + ...(params.sessionKey ? { s: params.sessionKey } : {}), + e: params.expiresAt, + ...(params.chatType ? { t: params.chatType } : {}), + }; +} diff --git a/extensions/feishu/src/monitor.account.ts b/extensions/feishu/src/monitor.account.ts index 3d761631399..241376ac0ba 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -10,6 +10,7 @@ import { type FeishuBotAddedEvent, } from "./bot.js"; import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js"; +import { maybeHandleFeishuQuickActionMenu } from "./card-ux-launcher.js"; import { createEventDispatcher } from "./client.js"; import { hasProcessedFeishuMessage, @@ -513,7 +514,7 @@ function registerEventHandlers( try { const event = data as { event_key?: string; - timestamp?: number; + timestamp?: string | number; operator?: { operator_name?: string; operator_id?: { open_id?: string; user_id?: string; union_id?: string }; @@ -543,14 +544,28 @@ function registerEventHandlers( }), }, }; - const promise = handleFeishuMessage({ + const handleLegacyMenu = () => + handleFeishuMessage({ + cfg, + event: syntheticEvent, + botOpenId: botOpenIds.get(accountId), + botName: botNames.get(accountId), + runtime, + chatHistories, + accountId, + }); + + const promise = maybeHandleFeishuQuickActionMenu({ cfg, - event: syntheticEvent, - botOpenId: botOpenIds.get(accountId), - botName: botNames.get(accountId), + eventKey, + operatorOpenId, runtime, - chatHistories, accountId, + }).then((handledMenu) => { + if (handledMenu) { + return; + } + return handleLegacyMenu(); }); if (fireAndForget) { promise.catch((err) => { diff --git a/extensions/feishu/src/monitor.bot-menu.test.ts b/extensions/feishu/src/monitor.bot-menu.test.ts new file mode 100644 index 00000000000..cecb0b0512c --- /dev/null +++ b/extensions/feishu/src/monitor.bot-menu.test.ts @@ -0,0 +1,229 @@ +import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { hasControlCommand } from "../../../src/auto-reply/command-detection.js"; +import { + createInboundDebouncer, + resolveInboundDebounceMs, +} from "../../../src/auto-reply/inbound-debounce.js"; +import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; +import { monitorSingleAccount } from "./monitor.account.js"; +import { setFeishuRuntime } from "./runtime.js"; +import type { ResolvedFeishuAccount } from "./types.js"; + +const createEventDispatcherMock = vi.hoisted(() => vi.fn()); +const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {})); +const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {})); +const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async () => {})); +const sendCardFeishuMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "m1", chatId: "c1" }))); +const createFeishuThreadBindingManagerMock = vi.hoisted(() => vi.fn(() => ({ stop: vi.fn() }))); + +let handlers: Record Promise> = {}; + +vi.mock("./client.js", () => ({ + createEventDispatcher: createEventDispatcherMock, +})); + +vi.mock("./monitor.transport.js", () => ({ + monitorWebSocket: monitorWebSocketMock, + monitorWebhook: monitorWebhookMock, +})); + +vi.mock("./bot.js", async () => { + const actual = await vi.importActual("./bot.js"); + return { + ...actual, + handleFeishuMessage: handleFeishuMessageMock, + }; +}); + +vi.mock("./send.js", async () => { + const actual = await vi.importActual("./send.js"); + return { + ...actual, + sendCardFeishu: sendCardFeishuMock, + }; +}); + +vi.mock("./thread-bindings.js", () => ({ + createFeishuThreadBindingManager: createFeishuThreadBindingManagerMock, +})); + +function buildAccount(): ResolvedFeishuAccount { + return { + accountId: "default", + enabled: true, + configured: true, + appId: "cli_test", + appSecret: "secret_test", // pragma: allowlist secret + domain: "feishu", + config: { + enabled: true, + connectionMode: "websocket", + }, + } as ResolvedFeishuAccount; +} + +async function registerHandlers() { + setFeishuRuntime( + createPluginRuntimeMock({ + channel: { + debounce: { + createInboundDebouncer, + resolveInboundDebounceMs, + }, + text: { + hasControlCommand, + }, + }, + }), + ); + const register = vi.fn((registered: Record Promise>) => { + handlers = registered; + }); + createEventDispatcherMock.mockReturnValue({ register }); + + await monitorSingleAccount({ + cfg: {} as ClawdbotConfig, + account: buildAccount(), + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + } as RuntimeEnv, + botOpenIdSource: { + kind: "prefetched", + botOpenId: "ou_bot", + botName: "Bot", + }, + }); + + const onBotMenu = handlers["application.bot.menu_v6"]; + if (!onBotMenu) { + throw new Error("missing application.bot.menu_v6 handler"); + } + return onBotMenu; +} + +describe("Feishu bot menu handler", () => { + beforeEach(() => { + handlers = {}; + vi.clearAllMocks(); + }); + + it("opens the quick-action launcher card at the webhook/event layer", async () => { + const onBotMenu = await registerHandlers(); + + await onBotMenu({ + event_key: "quick-actions", + timestamp: "1700000000000", + operator: { + operator_id: { + open_id: "ou_user1", + user_id: "user_1", + union_id: "union_1", + }, + }, + }); + + expect(sendCardFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "user:ou_user1", + card: expect.objectContaining({ + header: expect.objectContaining({ + title: expect.objectContaining({ content: "Quick actions" }), + }), + }), + }), + ); + expect(handleFeishuMessageMock).not.toHaveBeenCalled(); + }); + + it("does not block bot-menu handling on quick-action launcher send", async () => { + const onBotMenu = await registerHandlers(); + let resolveSend: (() => void) | undefined; + sendCardFeishuMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSend = () => resolve({ messageId: "m1", chatId: "c1" }); + }), + ); + + const pending = onBotMenu({ + event_key: "quick-actions", + timestamp: "1700000000000", + operator: { + operator_id: { + open_id: "ou_user1", + user_id: "user_1", + union_id: "union_1", + }, + }, + }); + let settled = false; + pending.finally(() => { + settled = true; + }); + + await Promise.resolve(); + expect(settled).toBe(true); + + resolveSend?.(); + await pending; + }); + + it("falls back to the legacy /menu synthetic message path for unrelated bot menu keys", async () => { + const onBotMenu = await registerHandlers(); + + await onBotMenu({ + event_key: "custom-key", + timestamp: "1700000000000", + operator: { + operator_id: { + open_id: "ou_user1", + user_id: "user_1", + union_id: "union_1", + }, + }, + }); + + expect(handleFeishuMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + message: expect.objectContaining({ + content: '{"text":"/menu custom-key"}', + }), + }), + }), + ); + expect(sendCardFeishuMock).not.toHaveBeenCalled(); + }); + + it("falls back to the legacy /menu path when launcher rendering fails", async () => { + const onBotMenu = await registerHandlers(); + sendCardFeishuMock.mockRejectedValueOnce(new Error("boom")); + + await onBotMenu({ + event_key: "quick-actions", + timestamp: "1700000000000", + operator: { + operator_id: { + open_id: "ou_user1", + user_id: "user_1", + union_id: "union_1", + }, + }, + }); + + await vi.waitFor(() => { + expect(handleFeishuMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + message: expect.objectContaining({ + content: '{"text":"/menu quick-actions"}', + }), + }), + }), + ); + }); + }); +}); diff --git a/src/security/audit.ts b/src/security/audit.ts index b304f658d68..0b13ecc5531 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -1250,13 +1250,16 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise Date: Mon, 16 Mar 2026 06:08:41 +0000 Subject: [PATCH 23/23] build: remove land gate script --- docs/ci.md | 3 --- package.json | 1 - 2 files changed, 4 deletions(-) diff --git a/docs/ci.md b/docs/ci.md index 25445d6c0ed..e8710b87cb1 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -53,7 +53,4 @@ pnpm check # types + lint + format pnpm test # vitest tests pnpm check:docs # docs format + lint + broken links pnpm release:check # validate npm pack -pnpm land:gate # maintainer land gate: frozen-lock install + check + build + test + release:check ``` - -`pnpm land:gate` intentionally includes the same frozen-lockfile install step CI uses before running `check`, `build`, `test`, and `release:check`. Use it when you want local merge-gate parity instead of piecemeal commands. diff --git a/package.json b/package.json index 124975e63d1..5aeb794f174 100644 --- a/package.json +++ b/package.json @@ -270,7 +270,6 @@ "ios:gen": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate'", "ios:open": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'", "ios:run": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted ai.openclaw.ios'", - "land:gate": "pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true && pnpm check && pnpm build && pnpm test && pnpm release:check", "lint": "oxlint --type-aware", "lint:agent:ingress-owner": "node scripts/check-ingress-agent-owner-context.mjs", "lint:all": "pnpm lint && pnpm lint:swift",