refactor: dedupe shared helpers

This commit is contained in:
Peter Steinberger 2026-04-24 08:26:26 +01:00
parent 661f11b947
commit 69196670b7
No known key found for this signature in database
16 changed files with 293 additions and 509 deletions

View file

@ -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<GeminiCliOAuthCredentials> {
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);
}

View file

@ -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<string | undefined> {
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: {

View file

@ -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<ResolvedSlackAccount>({
sectionKey: SLACK_CHANNEL,
listAccountIds: listSlackAccountIds,

View file

@ -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<string | undefined> {
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: {

View file

@ -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",

View file

@ -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*)<Accordion\b/u);
if (openAccordion) {
accordionStack.push({
indent: openAccordion[1].length,
line: index + 1,
hasOutdentedListItem: false,
});
continue;
}
const listItem = line.match(/^(\s*)[-*+]\s+/u);
if (listItem) {
for (const accordion of accordionStack) {
if (listItem[1].length < accordion.indent) {
accordion.hasOutdentedListItem = true;
}
}
}
const closeAccordion = line.match(/^(\s*)<\/Accordion>/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) {

View file

@ -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*)<Accordion\b/u);
if (openAccordion) {
accordionStack.push({
indent: openAccordion[1].length,
hasOutdentedListItem: false,
});
continue;
}
const listItem = line.match(/^(\s*)[-*+]\s+/u);
if (listItem) {
for (const accordion of accordionStack) {
if (listItem[1].length < accordion.indent) {
accordion.hasOutdentedListItem = true;
}
}
}
const closeAccordion = line.match(/^(\s*)<\/Accordion>/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) {

View file

@ -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*)<Accordion\b/u);
if (openAccordion) {
accordionStack.push({
indent: openAccordion[1].length,
hasOutdentedListItem: false,
});
continue;
}
const listItem = line.match(/^(\s*)[-*+]\s+/u);
if (listItem) {
for (const accordion of accordionStack) {
if (listItem[1].length < accordion.indent) {
accordion.hasOutdentedListItem = true;
}
}
}
const closeAccordion = line.match(/^(\s*)<\/Accordion>/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;
}

View file

@ -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 =

View file

@ -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<string, unknown>;
@ -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(

View file

@ -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;

View file

@ -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");
}

View file

@ -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,

View file

@ -78,6 +78,35 @@ function shouldSuppressListModel(params: {
});
}
function appendVisibleRow(params: {
rows: ModelRow[];
model: ListRowModel;
key: string;
context: RowBuilderContext;
seenKeys?: Set<string>;
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<ModelDefinitionConfig>;
}): 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;
}

View file

@ -35,6 +35,8 @@ export {
asBoolean,
asFiniteNumber,
asObject,
extractProviderErrorDetail,
formatProviderErrorPayload,
readResponseTextLimited,
trimToUndefined,
truncateErrorDetail,

View file

@ -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<string | undefined> {
const rawBody = trimToUndefined(await readResponseTextLimited(response));
if (!rawBody) {
return undefined;
}
try {
return formatProviderErrorPayload(JSON.parse(rawBody)) ?? truncateErrorDetail(rawBody);
} catch {
return truncateErrorDetail(rawBody);
}
}