diff --git a/extensions/google/oauth.ts b/extensions/google/oauth.ts index ff959daae1f..2199269e568 100644 --- a/extensions/google/oauth.ts +++ b/extensions/google/oauth.ts @@ -37,19 +37,7 @@ export async function loginGeminiCliOAuth( const authUrl = buildAuthUrl(challenge, state); if (needsManual) { - ctx.progress.update("OAuth URL ready"); - ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`); - ctx.progress.update("Waiting for you to paste the callback URL..."); - const callbackInput = await ctx.prompt("Paste the redirect URL here: "); - const parsed = parseCallbackInput(callbackInput); - if ("error" in parsed) { - throw new Error(parsed.error); - } - if (parsed.state !== state) { - throw new Error("OAuth state mismatch - please try again"); - } - ctx.progress.update("Exchanging authorization code for tokens..."); - return exchangeCodeForTokens(parsed.code, verifier); + return manualFlow(ctx, authUrl, state, verifier); } ctx.progress.update("Complete sign-in in browser..."); @@ -75,18 +63,30 @@ export async function loginGeminiCliOAuth( err.message.includes("listen")) ) { ctx.progress.update("Local callback server failed. Switching to manual mode..."); - ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`); - const callbackInput = await ctx.prompt("Paste the redirect URL here: "); - const parsed = parseCallbackInput(callbackInput); - if ("error" in parsed) { - throw new Error(parsed.error, { cause: err }); - } - if (parsed.state !== state) { - throw new Error("OAuth state mismatch - please try again", { cause: err }); - } - ctx.progress.update("Exchanging authorization code for tokens..."); - return exchangeCodeForTokens(parsed.code, verifier); + return manualFlow(ctx, authUrl, state, verifier, err); } throw err; } } + +async function manualFlow( + ctx: GeminiCliOAuthContext, + authUrl: string, + state: string, + verifier: string, + cause?: Error, +): Promise { + ctx.progress.update("OAuth URL ready"); + ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`); + ctx.progress.update("Waiting for you to paste the callback URL..."); + const callbackInput = await ctx.prompt("Paste the redirect URL here: "); + const parsed = parseCallbackInput(callbackInput); + if ("error" in parsed) { + throw new Error(parsed.error, cause ? { cause } : undefined); + } + if (parsed.state !== state) { + throw new Error("OAuth state mismatch - please try again", cause ? { cause } : undefined); + } + ctx.progress.update("Exchanging authorization code for tokens..."); + return exchangeCodeForTokens(parsed.code, verifier); +} diff --git a/extensions/openai/tts.ts b/extensions/openai/tts.ts index ce004e4cc04..5c8570cd67d 100644 --- a/extensions/openai/tts.ts +++ b/extensions/openai/tts.ts @@ -2,12 +2,7 @@ import { captureHttpExchange, isDebugProxyGlobalFetchPatchInstalled, } from "openclaw/plugin-sdk/proxy-capture"; -import { - asObject, - readResponseTextLimited, - trimToUndefined, - truncateErrorDetail, -} from "openclaw/plugin-sdk/speech"; +import { extractProviderErrorDetail, trimToUndefined } from "openclaw/plugin-sdk/speech"; export const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"; @@ -69,43 +64,8 @@ export function resolveOpenAITtsInstructions( return next && model.includes("gpt-4o-mini-tts") ? next : undefined; } -function formatOpenAiErrorPayload(payload: unknown): string | undefined { - const root = asObject(payload); - const subject = asObject(root?.error) ?? root; - if (!subject) { - return undefined; - } - const message = - trimToUndefined(subject.message) ?? - trimToUndefined(subject.detail) ?? - trimToUndefined(root?.message); - const type = trimToUndefined(subject.type); - const code = trimToUndefined(subject.code); - const metadata = [type ? `type=${type}` : undefined, code ? `code=${code}` : undefined] - .filter((value): value is string => Boolean(value)) - .join(", "); - if (message && metadata) { - return `${truncateErrorDetail(message)} [${metadata}]`; - } - if (message) { - return truncateErrorDetail(message); - } - if (metadata) { - return `[${metadata}]`; - } - return undefined; -} - async function extractOpenAiErrorDetail(response: Response): Promise { - const rawBody = trimToUndefined(await readResponseTextLimited(response)); - if (!rawBody) { - return undefined; - } - try { - return formatOpenAiErrorPayload(JSON.parse(rawBody)) ?? truncateErrorDetail(rawBody); - } catch { - return truncateErrorDetail(rawBody); - } + return await extractProviderErrorDetail(response); } export async function openaiTTS(params: { diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts index 0c50325cd1d..e008da6f892 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -4,9 +4,6 @@ import { adaptScopedAccountAccessor, createScopedChannelConfigAdapter, } from "openclaw/plugin-sdk/channel-config-helpers"; -import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input"; -import { patchChannelConfigForAccount } from "openclaw/plugin-sdk/setup-runtime"; -import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, @@ -14,118 +11,20 @@ import { resolveSlackAccount, type ResolvedSlackAccount, } from "./accounts.js"; -import { getChatChannelMeta, type ChannelPlugin, type OpenClawConfig } from "./channel-api.js"; +import { getChatChannelMeta, type ChannelPlugin } from "./channel-api.js"; import { SlackChannelConfigSchema } from "./config-schema.js"; import { slackDoctor } from "./doctor.js"; import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js"; import { slackSecurityAdapter } from "./security.js"; +import { SLACK_CHANNEL } from "./setup-shared.js"; -export const SLACK_CHANNEL = "slack" as const; - -function buildSlackManifest(botName: string) { - const safeName = botName.trim() || "OpenClaw"; - const manifest = { - display_information: { - name: safeName, - description: `${safeName} connector for OpenClaw`, - }, - features: { - bot_user: { - display_name: safeName, - always_online: true, - }, - app_home: { - messages_tab_enabled: true, - messages_tab_read_only_enabled: false, - }, - slash_commands: [ - { - command: "/openclaw", - description: "Send a message to OpenClaw", - should_escape: false, - }, - ], - }, - oauth_config: { - scopes: { - bot: [ - "app_mentions:read", - "assistant:write", - "channels:history", - "channels:read", - "chat:write", - "commands", - "emoji:read", - "files:read", - "files:write", - "groups:history", - "groups:read", - "im:history", - "im:read", - "im:write", - "mpim:history", - "mpim:read", - "mpim:write", - "pins:read", - "pins:write", - "reactions:read", - "reactions:write", - "users:read", - ], - }, - }, - settings: { - socket_mode_enabled: true, - event_subscriptions: { - bot_events: [ - "app_mention", - "channel_rename", - "member_joined_channel", - "member_left_channel", - "message.channels", - "message.groups", - "message.im", - "message.mpim", - "pin_added", - "pin_removed", - "reaction_added", - "reaction_removed", - ], - }, - }, - }; - return JSON.stringify(manifest, null, 2); -} - -export function buildSlackSetupLines(botName = "OpenClaw"): string[] { - return [ - "1) Slack API -> Create App -> From scratch or From manifest (with the JSON below)", - "2) Add Socket Mode + enable it to get the app-level token (xapp-...)", - "3) Install App to workspace to get the xoxb- bot token", - "4) Enable Event Subscriptions (socket) for message events", - "5) App Home -> enable the Messages tab for DMs", - "Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.", - `Docs: ${formatDocsLink("/slack", "slack")}`, - "", - "Manifest (JSON):", - buildSlackManifest(botName), - ]; -} - -export function setSlackChannelAllowlist( - cfg: OpenClawConfig, - accountId: string, - channelKeys: string[], -): OpenClawConfig { - const channels = Object.fromEntries(channelKeys.map((key) => [key, { enabled: true }])); - return patchChannelConfigForAccount({ - cfg, - channel: SLACK_CHANNEL, - accountId, - patch: { channels }, - }); -} +export { + buildSlackSetupLines, + isSlackSetupAccountConfigured, + setSlackChannelAllowlist, + SLACK_CHANNEL, +} from "./setup-shared.js"; export function isSlackPluginAccountConfigured(account: ResolvedSlackAccount): boolean { const mode = account.config.mode ?? "socket"; @@ -139,14 +38,6 @@ export function isSlackPluginAccountConfigured(account: ResolvedSlackAccount): b return Boolean(account.appToken?.trim()); } -export function isSlackSetupAccountConfigured(account: ResolvedSlackAccount): boolean { - const hasConfiguredBotToken = - Boolean(account.botToken?.trim()) || hasConfiguredSecretInput(account.config.botToken); - const hasConfiguredAppToken = - Boolean(account.appToken?.trim()) || hasConfiguredSecretInput(account.config.appToken); - return hasConfiguredBotToken && hasConfiguredAppToken; -} - export const slackConfigAdapter = createScopedChannelConfigAdapter({ sectionKey: SLACK_CHANNEL, listAccountIds: listSlackAccountIds, diff --git a/extensions/xai/tts.ts b/extensions/xai/tts.ts index 5ddedf52ff2..cc2f61622b4 100644 --- a/extensions/xai/tts.ts +++ b/extensions/xai/tts.ts @@ -1,10 +1,5 @@ import { postJsonRequest } from "openclaw/plugin-sdk/provider-http"; -import { - asObject, - readResponseTextLimited, - trimToUndefined, - truncateErrorDetail, -} from "openclaw/plugin-sdk/speech"; +import { extractProviderErrorDetail, trimToUndefined } from "openclaw/plugin-sdk/speech"; import { XAI_BASE_URL } from "./api.js"; export { XAI_BASE_URL }; @@ -44,43 +39,8 @@ export function normalizeXaiLanguageCode(value: unknown): string | undefined { ); } -function formatXaiErrorPayload(payload: unknown): string | undefined { - const root = asObject(payload); - const subject = asObject(root?.error) ?? root; - if (!subject) { - return undefined; - } - const message = - trimToUndefined(subject.message) ?? - trimToUndefined(subject.detail) ?? - trimToUndefined(root?.message); - const type = trimToUndefined(subject.type); - const code = trimToUndefined(subject.code); - const metadata = [type ? `type=${type}` : undefined, code ? `code=${code}` : undefined] - .filter((value): value is string => Boolean(value)) - .join(", "); - if (message && metadata) { - return `${truncateErrorDetail(message)} [${metadata}]`; - } - if (message) { - return truncateErrorDetail(message); - } - if (metadata) { - return `[${metadata}]`; - } - return undefined; -} - async function extractXaiErrorDetail(response: Response): Promise { - const rawBody = trimToUndefined(await readResponseTextLimited(response)); - if (!rawBody) { - return undefined; - } - try { - return formatXaiErrorPayload(JSON.parse(rawBody)) ?? truncateErrorDetail(rawBody); - } catch { - return truncateErrorDetail(rawBody); - } + return await extractProviderErrorDetail(response); } export async function xaiTTS(params: { diff --git a/package.json b/package.json index 140344d0df1..b1e00e6a122 100644 --- a/package.json +++ b/package.json @@ -1336,8 +1336,8 @@ "docs:list": "node scripts/docs-list.js", "docs:spellcheck": "bash scripts/docs-spellcheck.sh", "docs:spellcheck:fix": "bash scripts/docs-spellcheck.sh --write", - "dup:check": "jscpd src extensions test scripts --format typescript,javascript --pattern \"**/*.{ts,tsx,js,mjs,cjs}\" --gitignore --noSymlinks --ignore \"**/node_modules/**,**/dist/**,**/.git/**,**/coverage/**,**/build/**,**/.build/**,**/.artifacts/**\" --min-lines 12 --min-tokens 80 --reporters console", - "dup:check:json": "jscpd src extensions test scripts --format typescript,javascript --pattern \"**/*.{ts,tsx,js,mjs,cjs}\" --gitignore --noSymlinks --ignore \"**/node_modules/**,**/dist/**,**/.git/**,**/coverage/**,**/build/**,**/.build/**,**/.artifacts/**\" --min-lines 12 --min-tokens 80 --reporters json --output .artifacts/jscpd", + "dup:check": "jscpd src extensions scripts --format typescript,javascript --pattern \"**/*.{ts,tsx,js,mjs,cjs}\" --gitignore --noSymlinks --ignore \"**/*.test.ts,**/*.test.tsx,**/*.test.js,extensions/qa-matrix/src/shared/**,extensions/qa-matrix/src/report.ts,extensions/qa-matrix/src/docker-runtime.ts,extensions/qa-matrix/src/cli-paths.ts,**/node_modules/**,**/dist/**,**/.git/**,**/coverage/**,**/build/**,**/.build/**,**/.artifacts/**\" --min-lines 50 --min-tokens 300 --reporters console", + "dup:check:json": "jscpd src extensions scripts --format typescript,javascript --pattern \"**/*.{ts,tsx,js,mjs,cjs}\" --gitignore --noSymlinks --ignore \"**/*.test.ts,**/*.test.tsx,**/*.test.js,extensions/qa-matrix/src/shared/**,extensions/qa-matrix/src/report.ts,extensions/qa-matrix/src/docker-runtime.ts,extensions/qa-matrix/src/cli-paths.ts,**/node_modules/**,**/dist/**,**/.git/**,**/coverage/**,**/build/**,**/.build/**,**/.artifacts/**\" --min-lines 50 --min-tokens 300 --reporters json --output .artifacts/jscpd", "format": "oxfmt --write --threads=1", "format:all": "pnpm format && pnpm format:swift", "format:check": "oxfmt --check --threads=1", diff --git a/scripts/check-docs-mdx.mjs b/scripts/check-docs-mdx.mjs index ceac2e949d7..3d63f121ebb 100644 --- a/scripts/check-docs-mdx.mjs +++ b/scripts/check-docs-mdx.mjs @@ -3,6 +3,10 @@ import fs from "node:fs"; import path from "node:path"; import { compile } from "@mdx-js/mdx"; +import { + checkMintlifyAccordionIndentation, + MINTLIFY_ACCORDION_INDENT_MESSAGE, +} from "./lib/mintlify-accordion.mjs"; const MINTLIFY_LANGUAGE_CODES = new Set([ "en", @@ -120,59 +124,13 @@ function formatMdxError(filePath, error) { } function checkMintlifyMdxStructure(filePath, raw) { - const errors = []; - const lines = stripFrontmatter(raw).split(/\r?\n/u); - const accordionStack = []; - let inCodeFence = false; - - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index]; - if (/^\s*(```|~~~)/u.test(line)) { - inCodeFence = !inCodeFence; - continue; - } - if (inCodeFence) { - continue; - } - - const openAccordion = line.match(/^(\s*)/u); - if (!closeAccordion) { - continue; - } - - const opening = accordionStack.pop(); - if (opening && opening.hasOutdentedListItem && closeAccordion[1].length > opening.indent) { - errors.push({ - type: "mintlify-mdx", - file: filePath, - line: index + 1, - column: closeAccordion[1].length + 1, - message: - "Accordion closing tag is indented deeper than its opening tag; Mintlify can parse following markdown as nested content.", - }); - } - } - - return errors; + return checkMintlifyAccordionIndentation(stripFrontmatter(raw)).map((error) => ({ + type: "mintlify-mdx", + file: filePath, + line: error.line, + column: error.column, + message: MINTLIFY_ACCORDION_INDENT_MESSAGE, + })); } async function checkMdxFile(filePath) { diff --git a/scripts/docs-sync-publish.mjs b/scripts/docs-sync-publish.mjs index 28267c08afd..0156a27c16d 100644 --- a/scripts/docs-sync-publish.mjs +++ b/scripts/docs-sync-publish.mjs @@ -4,6 +4,7 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { repairMintlifyAccordionIndentation } from "./lib/mintlify-accordion.mjs"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(HERE, ".."); @@ -14,6 +15,10 @@ const SYNC_SUPPORT_FILES = [ source: path.join(ROOT, "scripts", "check-docs-mdx.mjs"), target: path.join(".openclaw-sync", "check-docs-mdx.mjs"), }, + { + source: path.join(ROOT, "scripts", "lib", "mintlify-accordion.mjs"), + target: path.join(".openclaw-sync", "lib", "mintlify-accordion.mjs"), + }, { source: path.join(ROOT, ".github", "codex", "prompts", "docs-mdx-repair.md"), target: path.join(".openclaw-sync", "docs-mdx-repair.md"), @@ -284,55 +289,6 @@ function composeDocsConfig() { }; } -function repairMintlifyAccordionIndentation(raw) { - const lines = raw.split(/\r?\n/u); - const accordionStack = []; - let inCodeFence = false; - let changed = false; - - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index]; - if (/^\s*(```|~~~)/u.test(line)) { - inCodeFence = !inCodeFence; - continue; - } - if (inCodeFence) { - continue; - } - - const openAccordion = line.match(/^(\s*)/u); - if (!closeAccordion) { - continue; - } - - const opening = accordionStack.pop(); - if (opening && opening.hasOutdentedListItem && closeAccordion[1].length > opening.indent) { - lines[index] = `${" ".repeat(opening.indent)}${line.slice(closeAccordion[1].length)}`; - changed = true; - } - } - - return changed ? lines.join("\n") : raw; -} - function repairGeneratedLocaleDocs(targetDocsDir) { let repaired = 0; for (const locale of GENERATED_LOCALES) { diff --git a/scripts/lib/mintlify-accordion.mjs b/scripts/lib/mintlify-accordion.mjs new file mode 100644 index 00000000000..01c48b0e328 --- /dev/null +++ b/scripts/lib/mintlify-accordion.mjs @@ -0,0 +1,73 @@ +export const MINTLIFY_ACCORDION_INDENT_MESSAGE = + "Accordion closing tag is indented deeper than its opening tag; Mintlify can parse following markdown as nested content."; + +function visitAccordionIndentation(raw, onMisindentedClose) { + const lines = raw.split(/\r?\n/u); + const accordionStack = []; + let inCodeFence = false; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (/^\s*(```|~~~)/u.test(line)) { + inCodeFence = !inCodeFence; + continue; + } + if (inCodeFence) { + continue; + } + + const openAccordion = line.match(/^(\s*)/u); + if (!closeAccordion) { + continue; + } + + const opening = accordionStack.pop(); + if (opening && opening.hasOutdentedListItem && closeAccordion[1].length > opening.indent) { + onMisindentedClose({ closeAccordion, index, line, lines, opening }); + } + } + + return lines; +} + +export function checkMintlifyAccordionIndentation(raw) { + const errors = []; + visitAccordionIndentation(raw, ({ closeAccordion, index }) => { + errors.push({ + line: index + 1, + column: closeAccordion[1].length + 1, + message: MINTLIFY_ACCORDION_INDENT_MESSAGE, + }); + }); + return errors; +} + +export function repairMintlifyAccordionIndentation(raw) { + let changed = false; + const lines = visitAccordionIndentation( + raw, + ({ closeAccordion, index, line, lines, opening }) => { + lines[index] = `${" ".repeat(opening.indent)}${line.slice(closeAccordion[1].length)}`; + changed = true; + }, + ); + return changed ? lines.join("\n") : raw; +} diff --git a/scripts/openclaw-npm-postpublish-verify.ts b/scripts/openclaw-npm-postpublish-verify.ts index fab26bc2416..0520b01cdbd 100644 --- a/scripts/openclaw-npm-postpublish-verify.ts +++ b/scripts/openclaw-npm-postpublish-verify.ts @@ -11,6 +11,7 @@ import { rmSync, } from "node:fs"; import { builtinModules } from "node:module"; +import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { isAbsolute, join, relative } from "node:path"; import { pathToFileURL } from "node:url"; @@ -25,7 +26,6 @@ import { } from "./lib/bundled-plugin-root-runtime-mirrors.mjs"; import { runInstalledWorkspaceBootstrapSmoke } from "./lib/workspace-bootstrap-smoke.mjs"; import { parseReleaseVersion, resolveNpmCommandInvocation } from "./openclaw-npm-release-check.ts"; -import { createRequire } from "node:module"; type InstalledPackageJson = { version?: string; @@ -123,7 +123,10 @@ export function normalizeInstalledBinaryVersion(output: string): string { return versionMatch?.[0] ?? trimmed; } -function listDistJavaScriptFiles(packageRoot: string): string[] { +function listDistJavaScriptFiles( + packageRoot: string, + opts: { skipRelativePath?: (relativePath: string) => boolean } = {}, +): string[] { const distDir = join(packageRoot, "dist"); if (!existsSync(distDir)) { return []; @@ -138,6 +141,10 @@ function listDistJavaScriptFiles(packageRoot: string): string[] { } for (const entry of readdirSync(currentDir, { withFileTypes: true })) { const entryPath = join(currentDir, entry.name); + const relativePath = relative(distDir, entryPath).replaceAll("\\", "/"); + if (opts.skipRelativePath?.(relativePath)) { + continue; + } if (entry.isDirectory()) { pending.push(entryPath); continue; @@ -166,35 +173,9 @@ export function collectInstalledContextEngineRuntimeErrors(packageRoot: string): } function listInstalledRootDistJavaScriptFiles(packageRoot: string): string[] { - const distDir = join(packageRoot, "dist"); - if (!existsSync(distDir)) { - return []; - } - - const pending = [distDir]; - const files: string[] = []; - while (pending.length > 0) { - const currentDir = pending.pop(); - if (!currentDir) { - continue; - } - for (const entry of readdirSync(currentDir, { withFileTypes: true })) { - const entryPath = join(currentDir, entry.name); - const relativePath = relative(distDir, entryPath).replaceAll("\\", "/"); - if (relativePath.startsWith("extensions/")) { - continue; - } - if (entry.isDirectory()) { - pending.push(entryPath); - continue; - } - if (entry.isFile() && ROOT_DIST_JAVASCRIPT_MODULE_FILE_RE.test(entry.name)) { - files.push(entryPath); - } - } - } - - return files; + return listDistJavaScriptFiles(packageRoot, { + skipRelativePath: (relativePath) => relativePath.startsWith("extensions/"), + }); } type ParsedImportSpecifiersResult = diff --git a/src/auto-reply/reply/commands-reset.ts b/src/auto-reply/reply/commands-reset.ts index b69db564865..16e086d40be 100644 --- a/src/auto-reply/reply/commands-reset.ts +++ b/src/auto-reply/reply/commands-reset.ts @@ -4,12 +4,11 @@ import { resetConfiguredBindingTargetInPlace } from "../../channels/plugins/bind import { updateSessionStoreEntry } from "../../config/sessions/store.js"; import { logVerbose } from "../../globals.js"; import { isAcpSessionKey } from "../../routing/session-key.js"; -import { isInternalMessageChannel } from "../../utils/message-channel.js"; -import { resolveCommandAuthorization } from "../command-auth.js"; import { resolveBoundAcpThreadSessionKey } from "./commands-acp/targets.js"; import { emitResetCommandHooks, type ResetCommandAction } from "./commands-reset-hooks.js"; import { parseSoftResetCommand } from "./commands-reset-mode.js"; import type { CommandHandlerResult, HandleCommandsParams } from "./commands-types.js"; +import { isResetAuthorizedForContext } from "./reset-authorization.js"; function applyAcpResetTailContext(ctx: HandleCommandsParams["ctx"], resetTail: string): void { const mutableCtx = ctx as Record; @@ -23,26 +22,11 @@ function applyAcpResetTailContext(ctx: HandleCommandsParams["ctx"], resetTail: s } function isResetAuthorized(params: HandleCommandsParams): boolean { - const auth = resolveCommandAuthorization({ + return isResetAuthorizedForContext({ ctx: params.ctx, cfg: params.cfg, - commandAuthorized: params.ctx.CommandAuthorized === true, + commandAuthorized: params.command.isAuthorizedSender || params.ctx.CommandAuthorized === true, }); - if (!params.command.isAuthorizedSender && !auth.isAuthorizedSender) { - return false; - } - const provider = params.ctx.Provider; - const internalGatewayCaller = provider - ? isInternalMessageChannel(provider) - : isInternalMessageChannel(params.ctx.Surface); - if (!internalGatewayCaller) { - return true; - } - const scopes = params.ctx.GatewayClientScopes; - if (!Array.isArray(scopes) || scopes.length === 0) { - return true; - } - return scopes.includes("operator.admin"); } export async function maybeHandleResetCommand( diff --git a/src/auto-reply/reply/get-reply-directives-apply.ts b/src/auto-reply/reply/get-reply-directives-apply.ts index f7121372c6f..5bc4edf4374 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.ts @@ -213,6 +213,30 @@ export async function applyInlineDirectiveOverrides(params: { }; } + const directivePersistenceContext = { + directives, + effectiveModelDirective, + cfg, + agentDir, + sessionEntry, + sessionStore, + sessionKey, + storePath, + elevatedEnabled, + elevatedAllowed, + defaultProvider, + defaultModel, + aliasIndex, + allowedModelKeys: modelState.allowedModelKeys, + initialModelLabel, + formatModelSwitchEvent, + agentCfg, + messageProvider: ctx.Provider, + surface: ctx.Surface, + gatewayClientScopes: ctx.GatewayClientScopes, + senderIsOwner: command.senderIsOwner, + }; + if ( isDirectiveOnly({ directives, @@ -251,29 +275,9 @@ export async function applyInlineDirectiveOverrides(params: { const persisted = await ( await loadDirectivePersist() ).persistInlineDirectives({ - directives, - effectiveModelDirective, - cfg, - agentDir, - sessionEntry, - sessionStore, - sessionKey, - storePath, - elevatedEnabled, - elevatedAllowed, - defaultProvider, - defaultModel, - aliasIndex, - allowedModelKeys: modelState.allowedModelKeys, + ...directivePersistenceContext, provider, model, - initialModelLabel, - formatModelSwitchEvent, - agentCfg, - messageProvider: ctx.Provider, - surface: ctx.Surface, - gatewayClientScopes: ctx.GatewayClientScopes, - senderIsOwner: command.senderIsOwner, markLiveSwitchPending: true, }); const label = `${modelSelection.provider}/${modelSelection.model}`; @@ -399,29 +403,9 @@ export async function applyInlineDirectiveOverrides(params: { const persisted = await ( await loadDirectivePersist() ).persistInlineDirectives({ - directives, - effectiveModelDirective, - cfg, - agentDir, - sessionEntry, - sessionStore, - sessionKey, - storePath, - elevatedEnabled, - elevatedAllowed, - defaultProvider, - defaultModel, - aliasIndex, - allowedModelKeys: modelState.allowedModelKeys, + ...directivePersistenceContext, provider, model, - initialModelLabel, - formatModelSwitchEvent, - agentCfg, - messageProvider: ctx.Provider, - surface: ctx.Surface, - gatewayClientScopes: ctx.GatewayClientScopes, - senderIsOwner: command.senderIsOwner, }); provider = persisted.provider; model = persisted.model; diff --git a/src/auto-reply/reply/reset-authorization.ts b/src/auto-reply/reply/reset-authorization.ts new file mode 100644 index 00000000000..c7e0ec49aa1 --- /dev/null +++ b/src/auto-reply/reply/reset-authorization.ts @@ -0,0 +1,27 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { isInternalMessageChannel } from "../../utils/message-channel.js"; +import { resolveCommandAuthorization } from "../command-auth.js"; +import type { MsgContext } from "../templating.js"; + +export function isResetAuthorizedForContext(params: { + ctx: MsgContext; + cfg: OpenClawConfig; + commandAuthorized: boolean; +}): boolean { + const auth = resolveCommandAuthorization(params); + if (!params.commandAuthorized && !auth.isAuthorizedSender) { + return false; + } + const provider = params.ctx.Provider; + const internalGatewayCaller = provider + ? isInternalMessageChannel(provider) + : isInternalMessageChannel(params.ctx.Surface); + if (!internalGatewayCaller) { + return true; + } + const scopes = params.ctx.GatewayClientScopes; + if (!Array.isArray(scopes) || scopes.length === 0) { + return true; + } + return scopes.includes("operator.admin"); +} diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 48132583981..3b56e4de97e 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -45,8 +45,6 @@ import { normalizeOptionalString, } from "../../shared/string-coerce.js"; import { normalizeSessionDeliveryFields } from "../../utils/delivery-context.shared.js"; -import { isInternalMessageChannel } from "../../utils/message-channel.js"; -import { resolveCommandAuthorization } from "../command-auth.js"; import { normalizeCommandBody } from "../commands-registry.js"; import type { MsgContext, TemplateContext } from "../templating.js"; import { resolveEffectiveResetTargetSessionKey } from "./acp-reset-target.js"; @@ -54,6 +52,7 @@ import { parseSoftResetCommand } from "./commands-reset-mode.js"; import { resolveConversationBindingContextFromMessage } from "./conversation-binding-input.js"; import { normalizeInboundTextNewlines } from "./inbound-text.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; +import { isResetAuthorizedForContext } from "./reset-authorization.js"; import { maybeRetireLegacyMainDeliveryRoute, resolveLastChannelRaw, @@ -164,29 +163,6 @@ export type SessionInitResult = { triggerBodyNormalized: string; }; -function isResetAuthorizedForContext(params: { - ctx: MsgContext; - cfg: OpenClawConfig; - commandAuthorized: boolean; -}): boolean { - const auth = resolveCommandAuthorization(params); - if (!params.commandAuthorized && !auth.isAuthorizedSender) { - return false; - } - const provider = params.ctx.Provider; - const internalGatewayCaller = provider - ? isInternalMessageChannel(provider) - : isInternalMessageChannel(params.ctx.Surface); - if (!internalGatewayCaller) { - return true; - } - const scopes = params.ctx.GatewayClientScopes; - if (!Array.isArray(scopes) || scopes.length === 0) { - return true; - } - return scopes.includes("operator.admin"); -} - function resolveSessionConversationBindingContext( cfg: OpenClawConfig, ctx: MsgContext, diff --git a/src/commands/models/list.rows.ts b/src/commands/models/list.rows.ts index 536a02bbea9..0c63358790d 100644 --- a/src/commands/models/list.rows.ts +++ b/src/commands/models/list.rows.ts @@ -78,6 +78,35 @@ function shouldSuppressListModel(params: { }); } +function appendVisibleRow(params: { + rows: ModelRow[]; + model: ListRowModel; + key: string; + context: RowBuilderContext; + seenKeys?: Set; + allowProviderAvailabilityFallback?: boolean; +}): boolean { + if (params.seenKeys?.has(params.key)) { + return false; + } + if (!matchesRowFilter(params.context.filter, params.model)) { + return false; + } + if (shouldSuppressListModel({ model: params.model, context: params.context })) { + return false; + } + params.rows.push( + buildRow({ + model: params.model, + key: params.key, + context: params.context, + allowProviderAvailabilityFallback: params.allowProviderAvailabilityFallback, + }), + ); + params.seenKeys?.add(params.key); + return true; +} + function resolveConfiguredModelInput(params: { model: Partial; }): Array<"text" | "image"> { @@ -126,21 +155,8 @@ export function appendDiscoveredRows(params: { }); for (const model of sorted) { - if (shouldSuppressListModel({ model, context: params.context })) { - continue; - } - if (!matchesRowFilter(params.context.filter, model)) { - continue; - } const key = modelKey(model.provider, model.id); - params.rows.push( - buildRow({ - model, - key, - context: params.context, - }), - ); - seenKeys.add(key); + appendVisibleRow({ rows: params.rows, model, key, context: params.context, seenKeys }); } return seenKeys; @@ -159,29 +175,19 @@ export function appendConfiguredProviderRows(params: { continue; } const key = modelKey(provider, configuredModel.id); - if (params.seenKeys.has(key)) { - continue; - } const model = toConfiguredProviderListModel({ provider, providerConfig, model: configuredModel, }); - if (!matchesRowFilter(params.context.filter, model)) { - continue; - } - if (shouldSuppressListModel({ model, context: params.context })) { - continue; - } - params.rows.push( - buildRow({ - model, - key, - context: params.context, - allowProviderAvailabilityFallback: !params.context.discoveredKeys.has(key), - }), - ); - params.seenKeys.add(key); + appendVisibleRow({ + rows: params.rows, + model, + key, + context: params.context, + seenKeys: params.seenKeys, + allowProviderAvailabilityFallback: !params.context.discoveredKeys.has(key), + }); } } } @@ -201,30 +207,23 @@ export async function appendCatalogSupplementRows(params: { continue; } const key = modelKey(entry.provider, entry.id); - if (params.seenKeys.has(key)) { - continue; - } const model = resolveModelWithRegistry({ provider: entry.provider, modelId: entry.id, modelRegistry: params.modelRegistry, cfg: params.context.cfg, }); - if (!model || !matchesRowFilter(params.context.filter, model)) { + if (!model) { continue; } - if (shouldSuppressListModel({ model, context: params.context })) { - continue; - } - params.rows.push( - buildRow({ - model, - key, - context: params.context, - allowProviderAvailabilityFallback: !params.context.discoveredKeys.has(key), - }), - ); - params.seenKeys.add(key); + appendVisibleRow({ + rows: params.rows, + model, + key, + context: params.context, + seenKeys: params.seenKeys, + allowProviderAvailabilityFallback: !params.context.discoveredKeys.has(key), + }); } if (params.context.filter.local) { @@ -251,26 +250,19 @@ export async function appendProviderCatalogRows(params: { providerFilter: params.context.filter.provider, staticOnly: params.staticOnly, })) { - if (!matchesRowFilter(params.context.filter, model)) { - continue; - } - if (shouldSuppressListModel({ model, context: params.context })) { - continue; - } const key = modelKey(model.provider, model.id); - if (params.seenKeys.has(key)) { - continue; - } - params.rows.push( - buildRow({ + if ( + appendVisibleRow({ + rows: params.rows, model, key, context: params.context, + seenKeys: params.seenKeys, allowProviderAvailabilityFallback: !params.context.discoveredKeys.has(key), - }), - ); - params.seenKeys.add(key); - appended += 1; + }) + ) { + appended += 1; + } } return appended; } diff --git a/src/plugin-sdk/speech.ts b/src/plugin-sdk/speech.ts index 4f3ea1a64f6..272e98c9eb4 100644 --- a/src/plugin-sdk/speech.ts +++ b/src/plugin-sdk/speech.ts @@ -35,6 +35,8 @@ export { asBoolean, asFiniteNumber, asObject, + extractProviderErrorDetail, + formatProviderErrorPayload, readResponseTextLimited, trimToUndefined, truncateErrorDetail, diff --git a/src/tts/provider-error-utils.ts b/src/tts/provider-error-utils.ts index 6f4233c292e..9f39b020999 100644 --- a/src/tts/provider-error-utils.ts +++ b/src/tts/provider-error-utils.ts @@ -1,4 +1,5 @@ export { asFiniteNumber } from "../shared/number-coercion.js"; +import { normalizeOptionalString as trimToUndefined } from "../shared/string-coerce.js"; export { normalizeOptionalString as trimToUndefined } from "../shared/string-coerce.js"; export function asBoolean(value: unknown): boolean | undefined { @@ -63,3 +64,42 @@ export async function readResponseTextLimited( return text; } + +export function formatProviderErrorPayload(payload: unknown): string | undefined { + const root = asObject(payload); + const subject = asObject(root?.error) ?? root; + if (!subject) { + return undefined; + } + const message = + trimToUndefined(subject.message) ?? + trimToUndefined(subject.detail) ?? + trimToUndefined(root?.message); + const type = trimToUndefined(subject.type); + const code = trimToUndefined(subject.code); + const metadata = [type ? `type=${type}` : undefined, code ? `code=${code}` : undefined] + .filter((value): value is string => Boolean(value)) + .join(", "); + if (message && metadata) { + return `${truncateErrorDetail(message)} [${metadata}]`; + } + if (message) { + return truncateErrorDetail(message); + } + if (metadata) { + return `[${metadata}]`; + } + return undefined; +} + +export async function extractProviderErrorDetail(response: Response): Promise { + const rawBody = trimToUndefined(await readResponseTextLimited(response)); + if (!rawBody) { + return undefined; + } + try { + return formatProviderErrorPayload(JSON.parse(rawBody)) ?? truncateErrorDetail(rawBody); + } catch { + return truncateErrorDetail(rawBody); + } +}