From ab4adf71702e01020cf7ea612b594a5668a3c460 Mon Sep 17 00:00:00 2001 From: shayan919293 <60409704+shayan919293@users.noreply.github.com> Date: Fri, 13 Feb 2026 06:49:06 -0800 Subject: [PATCH] fix(macos): ensure exec approval prompt displays the command (#5042) * fix(config): migrate audio.transcription with any CLI command Two bugs fixed: 1. Removed CLI allowlist from mapLegacyAudioTranscription - the modern config format has no such restriction, so the allowlist only blocked legacy migration of valid configs like whisperx-transcribe.sh 2. Moved audio.transcription migration to a separate migration entry - it was nested inside routing.config-v2 which early-exited when no routing section existed Closes #5017 * fix(macos): ensure exec approval prompt displays the command The NSStackView and NSScrollView for the command text lacked proper width constraints, causing the accessory view to collapse to zero width in some cases. This fix: 1. Adds minimum width constraint (380px) to the root stack view 2. Adds minimum width constraint to the command scroll view 3. Enables vertical resizing and scrolling for long commands 4. Adds max height constraint to prevent excessively tall prompts Closes #5038 * fix: validate legacy audio transcription migration input (openclaw#5042) thanks @shayan919293 * docs: add changelog note for legacy audio migration guard (openclaw#5042) thanks @shayan919293 * fix: satisfy lint on audio transcription migration braces (openclaw#5042) thanks @shayan919293 --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../OpenClaw/ExecApprovalsSocket.swift | 9 ++- ...tion.rejects-routing-allowfrom.e2e.test.ts | 39 ++++++++++ src/config/legacy.migrations.part-2.ts | 73 ++++++++++--------- src/config/legacy.shared.ts | 16 ++-- 5 files changed, 97 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 829a5d85ec6..6b5c41be1d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Sessions/Agents: pass `agentId` through status and usage transcript-resolution paths (auto-reply, gateway usage APIs, and session cost/log loaders) so non-default agents can resolve absolute session files without path-validation failures. (#15103) Thanks @jalehman. - Signal/Install: auto-install `signal-cli` via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary `Exec format error` failures on arm64/arm hosts. (#15443) Thanks @jogvan-k. - Web tools/web_fetch: prefer `text/markdown` responses for Cloudflare Markdown for Agents, add `cf-markdown` extraction for markdown bodies, and redact fetched URLs in `x-markdown-tokens` debug logs to avoid leaking raw paths/query params. (#15376) Thanks @Yaxuan42. +- Config: keep legacy audio transcription migration strict by rejecting non-string/unsafe command tokens while still migrating valid custom script executables. (#5042) Thanks @shayan919293. ## 2026.2.12 diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift index f6d88c48302..c87dd1e5884 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift @@ -242,6 +242,8 @@ enum ExecApprovalsPromptPresenter { stack.orientation = .vertical stack.spacing = 8 stack.alignment = .leading + stack.translatesAutoresizingMaskIntoConstraints = false + stack.widthAnchor.constraint(greaterThanOrEqualToConstant: 380).isActive = true let commandTitle = NSTextField(labelWithString: "Command") commandTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize) @@ -258,16 +260,19 @@ enum ExecApprovalsPromptPresenter { commandText.textContainer?.lineFragmentPadding = 0 commandText.textContainer?.widthTracksTextView = true commandText.isHorizontallyResizable = false - commandText.isVerticallyResizable = false + commandText.isVerticallyResizable = true let commandScroll = NSScrollView() commandScroll.borderType = .lineBorder - commandScroll.hasVerticalScroller = false + commandScroll.hasVerticalScroller = true commandScroll.hasHorizontalScroller = false + commandScroll.autohidesScrollers = true commandScroll.documentView = commandText commandScroll.translatesAutoresizingMaskIntoConstraints = false + commandScroll.widthAnchor.constraint(greaterThanOrEqualToConstant: 380).isActive = true commandScroll.widthAnchor.constraint(lessThanOrEqualToConstant: 440).isActive = true commandScroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 56).isActive = true + commandScroll.heightAnchor.constraint(lessThanOrEqualToConstant: 120).isActive = true stack.addArrangedSubview(commandScroll) let contextTitle = NSTextField(labelWithString: "Context") diff --git a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.e2e.test.ts b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.e2e.test.ts index 1ef9bfb68f4..695239cf8e7 100644 --- a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.e2e.test.ts +++ b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.e2e.test.ts @@ -134,6 +134,45 @@ describe("legacy config detection", () => { }); expect(res.config?.routing).toBeUndefined(); }); + it("migrates audio.transcription with custom script names", async () => { + vi.resetModules(); + const { migrateLegacyConfig } = await import("./config.js"); + const res = migrateLegacyConfig({ + audio: { + transcription: { + command: ["/home/user/.scripts/whisperx-transcribe.sh"], + timeoutSeconds: 120, + }, + }, + }); + expect(res.changes).toContain("Moved audio.transcription → tools.media.audio.models."); + expect(res.config?.tools?.media?.audio).toEqual({ + enabled: true, + models: [ + { + command: "/home/user/.scripts/whisperx-transcribe.sh", + type: "cli", + timeoutSeconds: 120, + }, + ], + }); + expect(res.config?.audio).toBeUndefined(); + }); + it("rejects audio.transcription when command contains non-string parts", async () => { + vi.resetModules(); + const { migrateLegacyConfig } = await import("./config.js"); + const res = migrateLegacyConfig({ + audio: { + transcription: { + command: [{}], + timeoutSeconds: 120, + }, + }, + }); + expect(res.changes).toContain("Removed audio.transcription (invalid or empty command)."); + expect(res.config?.tools?.media?.audio).toBeUndefined(); + expect(res.config?.audio).toBeUndefined(); + }); it("migrates agent config into agents.defaults and tools", async () => { vi.resetModules(); const { migrateLegacyConfig } = await import("./config.js"); diff --git a/src/config/legacy.migrations.part-2.ts b/src/config/legacy.migrations.part-2.ts index c08020b146d..f9625856bb5 100644 --- a/src/config/legacy.migrations.part-2.ts +++ b/src/config/legacy.migrations.part-2.ts @@ -369,46 +369,53 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_2: LegacyConfigMigration[] = [ changes.push("Removed routing.transcribeAudio (tools.media.audio.models already set)."); } } else { - changes.push("Removed routing.transcribeAudio (unsupported transcription CLI)."); + changes.push("Removed routing.transcribeAudio (invalid or empty command)."); } delete routing.transcribeAudio; } - const audio = getRecord(raw.audio); - if (audio?.transcription !== undefined) { - const mapped = mapLegacyAudioTranscription(audio.transcription); - if (mapped) { - const tools = ensureRecord(raw, "tools"); - const media = ensureRecord(tools, "media"); - const mediaAudio = ensureRecord(media, "audio"); - const models = Array.isArray(mediaAudio.models) ? (mediaAudio.models as unknown[]) : []; - if (models.length === 0) { - mediaAudio.enabled = true; - mediaAudio.models = [mapped]; - changes.push("Moved audio.transcription → tools.media.audio.models."); - } else { - changes.push("Removed audio.transcription (tools.media.audio.models already set)."); - } - delete audio.transcription; - if (Object.keys(audio).length === 0) { - delete raw.audio; - } else { - raw.audio = audio; - } - } else { - delete audio.transcription; - changes.push("Removed audio.transcription (unsupported transcription CLI)."); - if (Object.keys(audio).length === 0) { - delete raw.audio; - } else { - raw.audio = audio; - } - } - } - if (Object.keys(routing).length === 0) { delete raw.routing; } }, }, + { + id: "audio.transcription-v2", + describe: "Move audio.transcription to tools.media.audio.models", + apply: (raw, changes) => { + const audio = getRecord(raw.audio); + if (audio?.transcription === undefined) { + return; + } + + const mapped = mapLegacyAudioTranscription(audio.transcription); + if (mapped) { + const tools = ensureRecord(raw, "tools"); + const media = ensureRecord(tools, "media"); + const mediaAudio = ensureRecord(media, "audio"); + const models = Array.isArray(mediaAudio.models) ? (mediaAudio.models as unknown[]) : []; + if (models.length === 0) { + mediaAudio.enabled = true; + mediaAudio.models = [mapped]; + changes.push("Moved audio.transcription → tools.media.audio.models."); + } else { + changes.push("Removed audio.transcription (tools.media.audio.models already set)."); + } + delete audio.transcription; + if (Object.keys(audio).length === 0) { + delete raw.audio; + } else { + raw.audio = audio; + } + } else { + delete audio.transcription; + changes.push("Removed audio.transcription (invalid or empty command)."); + if (Object.keys(audio).length === 0) { + delete raw.audio; + } else { + raw.audio = audio; + } + } + }, + }, ]; diff --git a/src/config/legacy.shared.ts b/src/config/legacy.shared.ts index 211e65459a0..3ffe911cff7 100644 --- a/src/config/legacy.shared.ts +++ b/src/config/legacy.shared.ts @@ -10,6 +10,7 @@ export type LegacyConfigMigration = { apply: (raw: Record, changes: string[]) => void; }; +import { isSafeExecutableValue } from "../infra/exec-safety.js"; import { isRecord } from "../utils.js"; export { isRecord }; @@ -45,24 +46,27 @@ export const mergeMissing = (target: Record, source: Record | null => { const transcriber = getRecord(value); const command = Array.isArray(transcriber?.command) ? transcriber?.command : null; if (!command || command.length === 0) { return null; } - const rawExecutable = String(command[0] ?? "").trim(); + if (typeof command[0] !== "string") { + return null; + } + if (!command.every((part) => typeof part === "string")) { + return null; + } + const rawExecutable = command[0].trim(); if (!rawExecutable) { return null; } - const executableName = rawExecutable.split(/[\\/]/).pop() ?? rawExecutable; - if (!AUDIO_TRANSCRIPTION_CLI_ALLOWLIST.has(executableName)) { + if (!isSafeExecutableValue(rawExecutable)) { return null; } - const args = command.slice(1).map((part) => String(part)); + const args = command.slice(1); const timeoutSeconds = typeof transcriber?.timeoutSeconds === "number" ? transcriber?.timeoutSeconds : undefined;